🔖

React Hook Form をデコレーターとして用いた Storybook を実装する

2024/07/10に公開

背景

社内 の Next.js で実装している Web サービスのフォーム部分を React Hook Form を用いています。一方、各コンポーネントのリスト化し確認するのに Storybook を用いています。
React Hook Form のコンテキストを含むコンポーネントを Storybook で表示するのにデコレーターが必要だったので備忘録としてまとめておきます。
同じようなことをしようとしている方の助けになれば幸いです。

ソフトウェアのバージョン

$ npm ls | grep next 
├── @storybook/nextjs@7.5.3
├── eslint-config-next@13.4.19
├── next-auth@4.24.7
├── next@13.5.6
$ npm ls | grep react-hook-form
├── react-hook-form@7.47.0
$ npm ls | grep storybook      
├── @storybook/addon-actions@7.6.17
├── @storybook/addon-essentials@7.5.3
├── @storybook/addon-interactions@7.5.3
├── @storybook/addon-links@7.5.3
├── @storybook/addon-onboarding@1.0.8
├── @storybook/blocks@7.5.3
├── @storybook/nextjs@7.5.3
├── @storybook/react@7.5.3
├── @storybook/testing-library@0.2.2
├── eslint-plugin-storybook@0.6.15
├── storybook@7.5.3

何が起こっているか

React Hook Form は Next.js で簡単にフォームを実装するのをサポートするライブラリです。

https://react-hook-form.com/

社内のサービスでも入力値のバリデーションや初期値のアサインなどを行なっています。

React Hook Form が用意している Hook を利用したい場合は、利用する予定のコンポーネントを FormProvider でネストしてあげる必要があります。

ネスト一例
return (
  <FormProvider {...methods}>
    <form onSubmit={methods.handleSubmit(() => onSubmit())}>
      <SingleText />
    </form>
  </FormProvider>
)

以上の例の場合 SingleText コンポーネントで React Hook Form の Hook などを利用できるようになります。

一方、 Storybook とは各コンポーネントがどのような見た目であるかを独立させて表示させて確認できるサービスです。開発者はこのページにはどういう見た目のどのコンポーネントを使うべきかなというのを Storybook 上で確認することができます。

この Storybook で自分が作成したコンポーネントを表示するのは以下のように実装したコンポーネントを呼び出してくる必要があります。

SingleText.stories.ts
import type { Meta, StoryObj } from '@storybook/react'
import SingleText from '../app/components/SingleText'

export default {
  title: 'Quest/SingleText',
  component: SingleText,
  parameters: {
    layout: 'centered',
  },
  args: [
    {
      id: 1,
      item: { '1': 'text' },
    },
  ],
} as Meta<typeof SingleText>

export const Primary: StoryObj<typeof SingleText> = {
  args: [
    {
      id: 1,
      item: { '1': '' },
    },
  ],
}

export const Secondary: StoryObj<typeof SingleText> = {
  args: [
    {
      id: 1,
      item: { '1': 'SingleText の初期値です' },
    },
  ],
}

しかし、繰り返しになりますが React Hook Form を利用しているコンポーネントの場合、 FormProvider でネストされている必要があります。上記のコードはネストされていないので実行すると以下のように少し見えにくいですが React Hook Form に関するエラーが出ます。

まとめると

  • React Hook Form : FormProvider にネストされている状態で使う必要がある
  • Storybook : コンポーネントを独立させて表示したい

というお互いの事情がうまくマッチしていない状態になってしまっていると言えます。

方法

解決方法としてこちらの gist に有志の方がまとめてくださっているのを見つけることができました。

https://gist.github.com/shumbo/3bbb8a2dea5ea0a90ecf0b7c103783e8

  1. Storybook と同じディレクトリに withRHT.tsx というコンポーネントを実装します。
withRHT.tsx
import { action } from '@storybook/addon-actions'
import { ReactNode, FC } from 'react'
import { FormProvider, useForm } from 'react-hook-form'

function StorybookFormProvider({
  children,
}: {
  children: ReactNode
}): JSX.Element {
  const methods = useForm()
  return (
    <FormProvider {...methods}>
      <form
        onSubmit={methods.handleSubmit(action('[React Hooks Form] Submit'))}
      >
        {children}
      </form>
    </FormProvider>
  )
}

export default function withRHF(showSubmitButton: boolean) {
  // eslint-disable-next-line func-names
  return function (Story: FC) {
    return (
      <StorybookFormProvider>
        <Story />
        {showSubmitButton && <button type="submit">Submit</button>}
      </StorybookFormProvider>
    )
  }
}

withRHF コンポーネントはユニットテストでいうモックのような存在にあたる StorybookFormProvider を呼び出しており、さらに StorybookFormProvider は React Hook Form の FormProvider を呼び出しています。

  1. React Hook Form を用いているコンポーネントの Storybook のデコレーターに withRHF を指定する

以下のようにデコレーターに withRHF を指定します。 withRHF(false)false は引数 showSubmitButton = false と指定していると言えます。

typescript
import type { Meta, StoryObj } from '@storybook/react'
import SingleText from '../app/components/SingleText'
+ import withRHF from './withRHF'

export default {
  title: 'Quest/SingleText',
  component: SingleText,
  parameters: {
    layout: 'centered',
  },
  args: [
    {
      id: 1,
      item: { '1': 'text' },
    },
  ],
+ decorators: [withRHF(false)],
} as Meta<typeof SingleText>

export const Primary: StoryObj<typeof SingleText> = {
  args: [
    {
      id: 1,
      item: { '1': '' },
    },
  ],
}

export const Secondary: StoryObj<typeof SingleText> = {
  args: [
    {
      id: 1,
      item: { '1': 'SingleText の初期値です' },
    },
  ],
}

こうすることで Storybook で表示する時に SingleText コンポーネントは withRHF コンポーネントでネストされている扱いになり、実質 FormProvider でネストされている状態と言えます。

これで以下のように Storybook 上でも描画することができました 🎉

💡 まとめ

  • React Hook Form に対応したデコレーターを実装することができました
  • React Hook Form を利用しているコンポーネントを Storybook に描画することができました

React Hook Form と Storybook はどちらも便利なライブラリであるため 「同時に使いたいけどなかなか同時に使った例が出てこないな」と苦労したので、まとめておきました。同じことで悩んでいる方の助けになれば幸いです。

Cykinso's Tech Blog

Discussion