🧬

Reactのprops管理をいい感じにするnpmパッケージをつくった

2022/09/22に公開

Reactのprops記法に満足していますか?

記法に関してVue派とReact派はよく揉める。
Vueの記法も綺麗だと思うけど、私はもうJSXを手放せないので趣味ではReactばかり書いている。

そんなReactお熱な私だが、propsの書き方に関してはVueが恋しくて仕方がない。

<script>
export default {
  props: {
    hoge1: {
      type: String,
      default: 'abc',
      required: true
    },
    hoge2: {
      type: String,
      default: '',
      required: false
    }
  }
}
</script>

型、デフォルト値、必須属性。全ての情報を一元管理できている感が素晴らしい。

一方のReactはPropsの型定義とデフォルト値定義を別オブジェクトで書く(書かざるを得ない?)。
デフォルト値に関してはオブジェクトにまとめるどころか、関数コンポーネントの引数部分にデフォルト引数としてベタ書きすることもしばしばある。
storybookを使っている場合は、storyのargTypesとFC引数の2箇所に同じ値をベタ書きする羽目になる。

なんとかならないものかと思い、実は数ヶ月前にちょっとしたオレオレ記法を実現するモジュールを作った。
それをようやくnpmパッケージとして公開してみたので、ヒソヒソと紹介してみる。

@polym/react-propsライブラリによる個人的な記法革命

https://www.npmjs.com/package/@polym/react-props

このライブラリを使うと、こんな感じの記法が実現できる。

import {
  getDefaultProps,
  getPropType,
  NotRequired,
  Required,
} from '@polym/react-props' // これが自作ライブラリ

const appearFromOptions = ['bottom', 'right'] as const
type AppearFromOptions = typeof appearFromOptions[number]

/* props設定オブジェクト */
const conf = {
  /* 必須 -> Required<型>() */
  startHeight: NotRequired<number>(),
  /* 必須じゃない -> NotRequired<型>(デフォルト値) */
  appearFrom: NotRequired<AppearFromOptions>('bottom'),
}

/* getPropTypeで全propsの型が得られる */
type PropType = getPropType<typeof conf>
/* 
  getDefaultPropsで全propsのデフォルト値をまとめたオブジェクトを生成 
  FCの引数で使ったりstorybookのargsに展開したり
*/
const defaultProps = getDefaultProps<AsFromProps>(conf)

ソースコードはこちら。
https://github.com/tetracalibers/polym-react-props
(TypeScript歴2週間くらいの頃に血反吐を吐きながら書いたので、正直見せられる代物じゃない…幸先悪いぞメンテナンス)

ところでpolymって何やねん

polymorphicの略です。

初めてつくったReactアプリを開発した当時、コンポーネントを一から作る技量がなかった私は当然の如くUIライブラリに全頼りしていた。
しかしデザインとしてはGlassmorphismを踏襲したかったので、materialデザインのUIライブラリのスタイルをひたすら上書きして塗り潰すという暴挙に出る。

そんな黒歴史とその結果生み出された魔のコードを回顧し、スマートな方法でデザインカスタマイズができる柔軟性の高いUIライブラリが欲しいなあ…と思い、polymorphicをコンセプトに掲げたpolymUiというUIコンポーネント集をちまちまつくっている。
今や遊び場と化しているpolymUiのstorybookはこちら

@polymというスコープで今後もいろいろなReactツールだったりUIだったりを出すつもりでいる。

実際のコンポーネント定義でつかってみる

メモ書きも兼ねて、@polym/react-propsライブラリを使ったサンプルコンポーネントのコードを掲載する。

デモ

今回つくるのは、アスペクト比を保ったままで画像のレスポンシブ対応を実現するAspectFrameコンポーネント(クリックするとデモに飛びます)

ディレクトリ構造

src
├── @types
│   └── import-image.d.ts
├── components
│   └── AspectFrame
│       ├── LayoutContainer.tsx
│       ├── index.tsx
│       └── model.ts
│   
├── index.ts
└── stories
    ├── AspectFrame.stories.tsx
    ├── args
    │   ├── AspectFrame.ts
    │   └── common
    │       ├── as.ts
    │       ├── children.ts
    │       └── ref.ts
    └── assets
        └── scenery.jpg

Next.jsで使う場合、コンポーネント定義ファイル内でコンポーネント以外の変数等をexportすると高速更新が無効になるので、story定義などでも使いまわすprops定義はmodel.tsというファイルに分離している。

props定義

@polym/react-propsライブラリでは、PolymorphicComponentPropWithRefPolymorphicComponentPropといったprops型も提供している。

これらはstyled-componentのようなas propsを持つコンポーネントを型安全に定義するための型。
例えば、

type CoreProps<As extends React.ElementType> = PolymorphicComponentPropWithRef<As>

のような記述は、

type CoreProps<As extends React.ElementType> = {
  as: As,
  children: React.ReactNode,
} & React.ComponentPropsWithRef<As>

とほぼ同義になる。(実際はAsStyledComponentでも適切に動作するように調整を加えている。)

src/components/AspectFrame/model.ts
import { NotRequired, getPropType, getDefaultProps, PolymorphicComponentPropWithRef } from '@polym/react-props'
import { ElementType } from 'react'

// コンポーネントの振る舞いを制御するprops
const conf = {
  ratioX: NotRequired<number>(16),
  ratioY: NotRequired<number>(9)
}
export type CharacterProps = getPropType<typeof conf>

// HTML属性や、Reactコンポーネントが最低限持つべきprops
export type CoreProps<As extends ElementType> =
  PolymorphicComponentPropWithRef<As>

// 全propsをまとめておく
export type AllProps<As extends ElementType> = CharacterProps & CoreProps<As>

// デフォルト値オブジェクト
export const defaultProps = getDefaultProps<CharacterProps>(conf)

style定義

アスペクト固定のレスポンシブ画像を実現するだけ。

src/components/AspectFrame/LayoutContainer.ts
import { CharacterProps } from './model'
import styled from 'styled-components'

export const LayoutContainer = styled.div<CharacterProps>`
  --height: ${({ ratioY }) => ratioY};
  --width: ${({ ratioX }) => ratioX};

  /* &&で優先度を上げてasが持つstyleを上書き */
  && {
    padding-bottom: calc(var(--height) / var(--width) * 100%);
    position: relative;
  }

  /* 直下の要素にだけスタイルが当たるように'>'は必須 */
  & > * {
    /* every-layoutのFrame CSSを丸写しなので割愛 */
  }

  & > img,
  & > video {
    /* every-layoutのFrame CSSを丸写しなので割愛 */
  }
`

Component定義

src/components/AspectFrame/index.ts
import { ElementType, forwardRef, ReactElement } from 'react'
import { LayoutContainer } from './LayoutContainer'
import { defaultProps, AllProps } from './model'
import { PolymorphicRef } from '@polym/react-props'

type AspectFrameComponent = <As extends ElementType>(
  props: AllProps<As>
) => ReactElement | null

// forwardRefで包むコンポーネントにもちゃんと名前をつける
const _AspectFrame = <As extends ElementType>(
  { as, children, ...props }: AllProps<As> = {
    ...defaultProps // デフォルト値をハードコーディングせずに済む
  } as AllProps<As>,
  // forwardRefの第二引数にrefを設定(しないと警告が出る)
  ref: PolymorphicRef<As>
) => {
  return (
    <LayoutContainer {...props} ref={ref} as={as || 'div'}>
      {children}
    </LayoutContainer>
  )
}

// story定義時にComponentStory<typeof AspectFrame>でエラーが出ないように、
// forwardRefで包んだ状態でAspectFrameComponent型をつけておく
export const AspectFrame: AspectFrameComponent = forwardRef(_AspectFrame)

story定義

argTypes

src/stories/args/AspectFrame.ts
import { defaultProps } from '../../components/AspectFrame/model'

export const desc_AspectFrameProps = {
  ratioX: {
    control: {
      type: 'number'
    },
    description: 'Width as denominator of aspect ratio',
    table: {
      type: {
        summary: null
      },
      category: 'style control',
      defaultValue: {
	// デフォルト値をハードコーディングしない
        summary: defaultProps.ratioX,
        details: null
      }
    }
  },
  ratioY: {
    control: {
      type: 'number'
    },
    description: 'Height as the numerator of the aspect ratio',
    table: {
      type: {
        summary: null
      },
      category: 'style control',
      defaultValue: {
        // デフォルト値をハードコーディングしない
        summary: defaultProps.ratioY,
        details: null
      }
    }
  }
}

aschildrenrefのargTypes定義は他のコンポーネントでも使うので別ファイルに切り出しておく。(これらの定義コードは割愛)

story

src/stories/AspectFrame.stories.tsx
import { ComponentStory } from '@storybook/react'
import { AspectFrame } from '../components/AspectFrame'
import { defaultProps } from '../components/AspectFrame/model'
import { desc_AspectFrameProps } from './args/AspectFrame'
import { desc_as } from './args/common/as'
import { desc_children } from './args/common/children'
import { desc_ref } from './args/common/ref'
import styled from 'styled-components'
// ここでエラーが出る。その対策は後述
import sampleImage from './assets/scenery.jpg'

export default {
  title: 'layout/AspectFrame',
  component: AspectFrame,
  parameters: {
    docs: {
      description: {
        component:
          'Suitable solution to crop the media to the specified aspect ratio'
      }
    }
  },
  argTypes: {
    ...desc_as,
    ...desc_ref,
    ...desc_children,
    ...desc_AspectFrameProps
  }
}

// asに指定してみるコンポーネント
const ExampleContainer = styled.div`
  box-shadow: rgba(3, 102, 214, 0.3) 0px 0px 0px 3px;
  height: 100%;
  width: 100%;
  box-sizing: padding-box;
`

const Template: ComponentStory<typeof AspectFrame> = ({
  children,
  ...args
}) => (
  <AspectFrame {...args} as={ExampleContainer}>
    <img src={sampleImage} />
  </AspectFrame>
)

export const playground = Template.bind({})
playground.args = {
  // デフォルト値を流し込んで表示
  ...defaultProps
}
画像importのエラー対策

tsconfig.jsonで設定したrootDirに@typesディレクトリを作成し、次のファイルを配置する。

src/@types/import-image.d.ts
declare module '*.jpg'

以上なり

記法の好みは人それぞれだし、私もひよっこの価値観しか持っていないので賛否あるだろうが、個人的に成長できる経験にはなったので良しとする。
もちろんライブラリは今後も育てていくので、不具合報告等いただけたらうれしいのです。

これからも変なものを量産しつつ、いつか大革命を起こせるようなライブラリがつくれたらいいなあと思いながら今日もコードを書くよ。

ご静聴ありがとうございました。

Discussion