💡

fetchに依存しない軽量なStorybookを構築するテクニック

2024/07/01に公開

こんにちは。この記事では、先日公開した軽量なStorybook駆動開発に関する記事の続編として、ネストしたContainer/Presentationコンポーネントに対して軽量なStorybookを構築する方法について紹介します。

軽量なStorybook

軽量なStorybookとは、Container/Presentation(C/P)パターンを活用してネットワークの関心を排除したStorybookのことを指しています。
APIモックツールを使用しないことでStorybookの実装容易性が向上することに加え、Portable Storiesを活用したテストの動作が安定するため、Storybook駆動開発の文化を醸成する手助けとなります。

詳細は前回の記事で紹介していますので、この記事と合わせてご覧ください。

https://zenn.dev/yuta_takahashi/articles/600e62c7ac7b3c

ネストしたContainer/PresentationをStorybookでどう扱うか?

C/Pパターンがネストしていて親子関係が密結合の場合、軽量なStorybook駆動開発を支えるコンポーネント設計で紹介した内容がうまく適用できません。

単純な実装をしている場合、親PresentationのStoryを構築すると内部で子Containerがレンダリングされるため、APIモックが必要になってしまう問題が生じます。

ネストしたC/Pパターンでは親PresentationのStoryで子Containerのネットワーク処理が実行されてしまう
Storybook: 親Presentation-> 子Container(ネットワーク処理の実行) -> 子Presentation

ネストしたC/Pの具体例:ユーザ情報設定ダイアログ

もう少し具体的な例でネストしたC/Pを考えていきましょう。以降の説明では、ユーザ情報設定ダイアログ(<UserSettingsFormDialog />)の実装を題材に扱います。


ユーザ情報設定ダイアログ

ユーザ情報設定ダイアログは以下の要件を満たすものとします。(重要な一部を抜粋)

  1. 「設定する」ボタンの押下時にユーザ情報を取得してフォームの初期値を設定する
  2. 「保存」ボタンを押すと保存処理が実行される
  3. 保存中はダイアログ右上の閉じるボタンが非表示になる
  4. 保存に完了するとダイアログが閉じる

ユーザ情報設定ダイアログの実装方針

先の要件を踏まえてユーザ情報設定ダイアログを実装するとき、筆者は次の2つにコンポーネントを分割します。

  1. <UserSettingsFormDialog />: フォーム部分を除くダイアログ全体のUIと状態管理を担当する親コンポーネント
  2. <UserSettingsForm />: フォームに関するのUIと状態管理およびネットワーク処理を担当する子コンポーネント

さらに、fetchに依存しない軽量なStorybookを構築するために、それぞれのコンポーネントをC/Pパターンで分割します。

次の表にそれぞれのコンポーネントの役割を示します。

コンポーネント C/P分類 役割
<UserSettingsFormDialog /> Container ユーザ情報の取得に必要な userId<UserSettingsForm />へ渡す(要件1)
<UserSettingsFormDialog /> Presentation 保存中における閉じるボタンの表示制御(要件3)、保存完了時のダイアログ制御(要件4)
<UserSettingsForm /> Container 初期状態の取得(要件1)、保存時のユーザ情報の更新(要件2)
<UserSettingsForm /> Presentation フォームのUIと状態制御(要件2)

fetchに依存しない軽量なStorybookを構築するテクニック

最初に結論を確認しましょう。次のコードは、親コンポーネントである<UserSettingsFormDialog />のStorybook定義です。ここで、userSettingsFormPropsを通して子コンポーネントのネットワークコンテキストをモックしていることに注目してください。Presentationレイヤーがfetchに依存しないため、ピュアなTypeScriptコードのみで実装できています。

UserSettingsFormDialog.stories.tsx
const meta = {
  title: 'useCases/user/UserSettingsFormDialog',
  component: UserSettingsFormDialog,
  args: {
    // 子コンポーネントのネットワークコンテキストをStorybook上でモックする
    // 1.フォームの初期値
    userSettingsFormProps: {
      initialValues: {
        familyName: '山田',
        givenName: '太郎',
        email: 'taro.yamada@example.com',
        birthday: { year: '1996', month: '12', day: '09' },
      },
      // 2.保存時のネットワーク処理
      updateUserSettings: async () => {
        await new Promise((resolve) => setTimeout(resolve, 500));
        return { success: true };
      },
    },
  },
} satisfies Meta<typeof UserSettingsFormDialog>;

ユーザ情報設定ダイアログの全体像を次のコンポーネントツリーに示します。以降の実装例と合わせてご確認ください。

ユーザ情報設定ダイアログの全体像
UserSettingsFormDialog
├── UserSettingsForm
│   ├── UserSettingsForm.container.tsx           # 子Container
│   ├── UserSettingsForm.schema.ts               # 子Presentationのフォームスキーマ
│   ├── UserSettingsForm.selector.ts             # 子Containerのビジネスロジック
│   ├── UserSettingsForm.tsx                     # 子Presentation
│   └── index.ts
├── UserSettingsFormDialog.container.context.tsx # Container Context
├── UserSettingsFormDialog.container.tsx         # 親Container
├── UserSettingsFormDialog.stories.tsx           # 親Story
├── UserSettingsFormDialog.tsx                   # 親Presentation
└── index.ts

これから紹介する3つのテクニックを活用することで、子コンポーネントのネットワークコンテキストを親コンポーネントのProps経由でモックすることが可能になります。

1. Container Contextでネットワーク処理のためのPropsをContainerレイヤーに閉じる

親コンポーネントから子Containerが必要とするネットワークパラメータを共有する場合は、Container Contextを使用します。次の例では子コンポーネントである<UserSettingsForm />のContainerがuseUserSettingsFormDialogContainerContextからuserIdを取得しています。

UserSettingsForm.container.tsx
export function UserSettingsForm({ onValid }: UserSettingsFormProps) {
  // ContainerContext経由で親ContainerからuserIdを取得
  const { userId } = useUserSettingsFormDialogContainerContext();
  // userIdを使用してfetch
  const { data: initialValues } = useSuspenseQuery({
    ...userQueries.details({ userId }),
    select: selectUserSettingsForm,
  });

  return (
    <Presenter
      initialValues={initialValues}
      onValid={onValid}
      updateUserSettings={/* 省略 */}
    />
  );
}

Container Contextの実装は非常に単純です。次のようにContextを定義して親Containerから子Containerへネットワーク処理に必要なパラメータを共有します。

Container Contextの定義
UserSettingsFormDialog.container.context.tsx
import { createContext, useContext } from 'react';

type UserSettingsFormDialogContainerContext = {
  userId: string;
};

export const UserSettingsFormDialogContainerContext = createContext<UserSettingsFormDialogContainerContext | undefined>(
  undefined,
);

export function useUserSettingsFormDialogContainerContext() {
  const context = useContext(UserSettingsFormDialogContainerContext);
  if (context === undefined) {
    throw new Error('useUserSettingsFormDialogContext must be used within a UserSettingsFormDialogContext.Provider');
  }
  return context;
}
UserSettingsFormDialog.container.tsx
import { useMemo } from 'react';
import { UserSettingsFormDialog as Presenter } from './UserSettingsFormDialog';
import { UserSettingsFormDialogContainerContext } from './UserSettingsFormDialog.container.context';

type UserSettingsFormDialogProps = {
  userId: string;
};

// Container
export function UserSettingsFormDialog({ userId }: UserSettingsFormDialogProps) {
  return (
    <UserSettingsFormDialogContainerContext.Provider value={useMemo(() => ({ userId }), [userId])}>
      <Presenter />
    </UserSettingsFormDialogContainerContext.Provider>
  );
}

Conrainer Contextを使うことで、すべてのPresentationレイヤーは userId を知る必要がなくなりました。userId はContainerレイヤーだけの関心事になります。

Container Contextを使用したuserIdのシーケンス

Container Context を使用しなかった場合は、次の図のようにPresentationレイヤーにネットワークのコンテキストが漏れてしまいます。これは、Storybook上で不要なuserIdを指定する必要が生じることを意味しています。

Container Contextを使用しないuserIdのシーケンス

2. 親のPresentationで子のC/Pを出し分ける

セクション冒頭で紹介したStorybookの実装例では、args.userSettingsFormPropsを通して子コンポーネントのネットワークコンテキストをモックしていたことを思い出してください。

【再掲】Storybookの実装例
UserSettingsFormDialog.stories.tsx
const meta = {
  title: 'useCases/user/UserSettingsFormDialog',
  component: UserSettingsFormDialog,
  args: {
    // 子コンポーネントのネットワークコンテキストをStorybook上でモックする
    // フォームの初期値
    userSettingsFormProps: {
      initialValues: {
        familyName: '山田',
        givenName: '太郎',
        email: 'taro.yamada@example.com',
        birthday: { year: '1996', month: '12', day: '09' },
      },
      // 保存時のネットワーク処理
      updateUserSettings: async () => {
        await new Promise((resolve) => setTimeout(resolve, 500));
        return { success: true };
      },
    },
  },
} satisfies Meta<typeof UserSettingsFormDialog>;

これを実現するためには、親PresentationコンポーネントのPropsで次の2つを定義します。

  1. Functional Component型の子コンポーネント
  2. Containerで注入される子PresentationコンポーネントのProps

以下に実装例を示します。<Suspense />内のコードに注目してください。userSettingsFormPropsが指定された場合はPresentationコンポーネントをレンダリングし、指定されてない場合はネットワーク処理を自律的に行うContainerコンポーネントをレンダリングします。

UserSettingsFormDialog.tsx
import {
  UserSettingsForm as UserSettingsFormPresenter,
  UserSettingsFormProps as UserSettingsFormPresenterProps,
} from './UserSettingsForm/UserSettingsForm';

type ContainerInjectedProps<
  ContainerProps extends Record<string, unknown>,
  PresenterProps extends Record<string, unknown>,
> = Omit<PresenterProps, keyof ContainerProps>;

export type UserSettingsFormDialogProps = {
  // 1. Functional Component型の子コンポーネント
  UserSettingsForm?: typeof UserSettingsFormPresenter;
  // 2. Containerで注入される子PresentationコンポーネントのProps
  // UserSettingsForm(Container)で注入されるPropsをStorybookで指定できるようにする
  userSettingsFormProps?: ContainerInjectedProps<UserSettingsFormContainerProps, UserSettingsFormPresenterProps>;
};

// Presentation
export function UserSettingsFormDialog({
  UserSettingsForm = UserSettingsFormPresenter,
  userSettingsFormProps,
}: UserSettingsFormDialogProps) {
  /* 中略 */
  const handleValid: UserSettingsFormPresenterProps['onValid'] = useCallback(
    /* 中略 */
  );
  return (
    <Dialog open={opened} onOpenChange={set}>
      <DialogContent>
        <div>
          <Suspense fallback={<div className="text-center">読み込み中...</div>}>
            {/* Storybook用Propsの指定有無でC/Pを出し分ける */}
            {userSettingsFormProps ? (
              <UserSettingsForm {...userSettingsFormProps} onValid={handleValid} />
            ) : (
              <UserSettingsFormContainer onValid={handleValid} />
            )}
          </Suspense>
        </div>
      </DialogContent>
    </Dialog>
  );
}

ここで注目すべきポイントは、ContainerInjectedPropsというUtility Typeです。ContainerInjectedProps はPresentationコンポーネントのPropsの中から、子コンポーネントのContainerで注入されるプロパティのみを返します。

ContainerInjectedPropsの説明
type PresenterProps = {
  initialValues?: UserSettingsForm;
  updateUserSettings: (values: UserSettingsForm) => Promise<MutateResult>;
  onValid: (params: {
    values: UserSettingsForm;
    updateUserSettings: UserSettingsFormProps['updateUserSettings'];
  }) => Promise<void>;
}

type ContainerProps = Pick<PresenterProps, 'onValid'>;

type InjectedProps = ContainerInjectedProps<ContainerProps, PresenterProps>;
     // ^? { initialValues?: UserSettingsForm; updateUserSettings: (values: UserSettingsForm) => Promise<MutateResult>; }

これによって、 onValidの値(handleValid)が親Presentation内にカプセル化されていることに気づくでしょう。フォーム送信時のダイアログ全体の挙動は親Presenterの責務として保証されていることが重要です。

UserSettingsForm?: typeof UserSettingsFormPresenter; をPropsで定義しているのは、Suspend状態のStoryを構築するためです。

UserSettingsFormDialog.stories.tsx
export const Loading: Story = {
  name: 'ローディング',
  args: {
    UserSettingsForm: () => {
      throw new Promise((resolve) => {
        resolve(true);
      });
    },
  },
  play: playOpen,
};

3. 継続渡しスタイルを使ったネットワーク処理のリフトアップ

最後に紹介するのは、継続渡しスタイルを使った非同期処理のリフトアップです。

継続渡しスタイル(CPS: Continuation-passing style)とは、関数がその結果を戻り値として返すのではなく、次に実行するべき関数(継続)に結果を渡すスタイルです。

次の例は、子コンポーネントで定義されたネットワーク処理を親コンポーネントに継続渡しする方法を示しています。handleValidは、送信されたフォームの値valuesと、次に実行する非同期処理updateUserSettingsを受け取って保存処理を実行しています。

<UserSettingsFormDialog />内で非同期処理を実行することで、state.statusを使用してフォームを保存時の状態に応じたダイアログ状態の制御を実現しています。

UserSettingsFormDialog.tsx
const initialState: UserSettingsFormDialogState = {
  status: 'idle',
  errorMessage: undefined,
};

// Presentation
export function UserSettingsFormDialog({
  UserSettingsForm = UserSettingsFormPresenter,
  userSettingsFormProps,
}: UserSettingsFormDialogProps) {
  const [opened, { open, close, set }] = useDisclosure();
  const [state, setState] = useState(initialState);

  // フォームが有効な場合のハンドラを定義
  const handleValid: UserSettingsFormPresenterProps['onValid'] = useCallback(
    // 子コンポーネントからフォームの値と非同期処理を受け取って実行する
    async ({ values, updateUserSettings }) => {
      setState((prev) => ({
        ...prev,
        status: 'loading',
      }));
      // 子Containerで定義したネットワーク処理を実行
      const result = await updateUserSettings(values);
      // 非同期処理の結果に応じて、ダイアログの表示状態を制御する
      if (result.success) {
        // 保存に成功した場合は閉じる
        close();
        setState(initialState);
      } else {
        // 保存に失敗した場合はエラーを表示する
        setState((prev) => ({
          ...prev,
          status: 'error',
          errorMessage: result.reason ?? '原因不明のエラーです。',
        }));
      }
    },
    [close],
  );

    return (
    <Dialog open={opened} onOpenChange={set}>
      <DialogTrigger asChild>
        <Button onClick={open} {...invokeButtonProps}>
          設定する
        </Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>ユーザ設定</DialogTitle>
        </DialogHeader>
        <div>
          {state.status === 'error' ? (
            <Alert variant="destructive" aria-labelledby="error-alert-title" aria-describedby="error-alert-description">
              <AlertCircle className="h-4 w-4" />
              <AlertTitle id="error-alert-title">ユーザ設定に失敗しました</AlertTitle>
              {state.errorMessage ? (
                <AlertDescription id="error-alert-description">{state.errorMessage}</AlertDescription>
              ) : null}
            </Alert>
          ) : null}
          <Suspense fallback={<div className="text-center">読み込み中...</div>}>
            {userSettingsFormProps ? (
              <UserSettingsForm {...userSettingsFormProps} onValid={handleValid} />
            ) : (
              <UserSettingsFormContainer onValid={handleValid} />
            )}
          </Suspense>
        </div>
        {/* 要件4. 保存中はダイアログ右上の閉じるボタンが非表示になる */}
        {state.status !== 'loading' ? <DialogClose /> : null}
      </DialogContent>
    </Dialog>
    );
}

まとめ

今回は、ネストしたC/Pパターンにおけるfetchに依存しないStorybookの構築方法を紹介しました。

  • Container Contextを使うことで、ネットワークに関連するPropsをContainerコンポーネントに閉じることができます。
  • 親Presentationで子コンポーネントのPropsを定義することでStorybook上でネットワークコンテキストをモックすることことができます。
  • 親のPresentationのレンダリングで子のC/Pを出し分けることによって、親Presentationの表示に関連する処理を適切にカプセル化することができます。
  • 非同期処理の関数を親コンポーネントへリフトアップすることで、処理を適切にCollocationしつつ、親子間で実行状態を共有することができます。

以上のテクニックを使うことでStorybookの実装容易性が向上し、その資材を使用したテストも安定します。その反面、アプリケーションコードの実装が複雑になるため、チームのスキルセットや開発文化によってトレードオフの選択になることでしょう。

さいごに、全体像を把握するために親コンポーネントのPresentation、子コンポーネントのContainer/Presentationを省略なしで掲載します。

Storybookの全体像
UserSettingsFormDialog.stories.tsx
import { type StoryObj, type Meta } from '@storybook/react';
import { userEvent, within, fireEvent } from '@storybook/test';
import { UserSettingsFormDialog } from './UserSettingsFormDialog';

const meta = {
  title: 'useCases/user/UserSettingsFormDialog',
  component: UserSettingsFormDialog,
  args: {
    userSettingsFormProps: {
      updateUserSettings: async () => {
        await new Promise((resolve) => setTimeout(resolve, 500));
        return { success: true };
      },
    },
  },
  parameters: {
    docs: {
      description: {
        component: 'Suspense境界を持つContainer/Presentationの実践例です。',
      },
    },
  },
} satisfies Meta<typeof UserSettingsFormDialog>;

export default meta;

type Story = StoryObj<typeof UserSettingsFormDialog>;

const playOpen: Story['play'] = async ({ canvasElement }) => {
  const canvas = within(canvasElement);
  await userEvent.click(canvas.getByRole('button', { name: '設定する' }));
};

const playFillName: Story['play'] = async ({ canvasElement }) => {
  const root = within(canvasElement.parentElement!);
  const dialog = within(root.getByRole('dialog', { name: 'ユーザ設定' }));
  await userEvent.type(dialog.getByLabelText('姓'), '山田');
  await userEvent.type(dialog.getByLabelText('名'), '太郎');
};

const playFillEmail: Story['play'] = async ({ canvasElement }) => {
  const root = within(canvasElement.parentElement!);
  const dialog = within(root.getByRole('dialog', { name: 'ユーザ設定' }));
  await userEvent.type(dialog.getByLabelText('メールアドレス'), 'taro.yamada@example.com');
};

const playFillBirthday: Story['play'] = async ({ canvasElement }) => {
  const root = within(canvasElement.parentElement!);
  const dialog = within(root.getByRole('dialog', { name: 'ユーザ設定' }));
  const birthday = within(dialog.getByRole('group', { name: '生年月日' }));
  await userEvent.click(birthday.getByRole('combobox', { name: '年' }));
  const yearSelect = within(root.getByRole('listbox', { name: '年' }));
  await userEvent.click(yearSelect.getByRole('option', { name: '1996年' }));
  await userEvent.click(birthday.getByRole('combobox', { name: '月' }));
  const monthSelect = within(root.getByRole('listbox', { name: '月' }));
  await userEvent.click(monthSelect.getByRole('option', { name: '11月' }));
  await userEvent.click(birthday.getByRole('combobox', { name: '日' }));
  const daySelect = within(root.getByRole('listbox', { name: '日' }));
  await userEvent.click(daySelect.getByRole('option', { name: '11日' }));
};

const playFillInvalidBirthday: Story['play'] = async ({ canvasElement }) => {
  const root = within(canvasElement.parentElement!);
  const dialog = within(root.getByRole('dialog', { name: 'ユーザ設定' }));
  const birthday = within(dialog.getByRole('group', { name: '生年月日' }));
  await userEvent.click(birthday.getByRole('combobox', { name: '年' }));
  const yearSelect = within(root.getByRole('listbox', { name: '年' }));
  await userEvent.click(yearSelect.getByRole('option', { name: '2024年' }));
  await userEvent.click(birthday.getByRole('combobox', { name: '月' }));
  const monthSelect = within(root.getByRole('listbox', { name: '月' }));
  await userEvent.click(monthSelect.getByRole('option', { name: '2月' }));
  await userEvent.click(birthday.getByRole('combobox', { name: '日' }));
  const daySelect = within(root.getByRole('listbox', { name: '日' }));
  await userEvent.click(daySelect.getByRole('option', { name: '31日' }));
};

const playFillAll: Story['play'] = async (args) => {
  await playFillName(args);
  await playFillEmail(args);
  await playFillBirthday(args);
};

const playSubmit: Story['play'] = async ({ canvasElement }) => {
  const root = within(canvasElement.parentElement!);
  const dialog = within(root.getByRole('dialog', { name: 'ユーザ設定' }));
  await fireEvent.click(dialog.getByRole('button', { name: '保存' }));
};

export const Default: Story = {
  name: '初期表示',
  play: playOpen,
};

export const Edit: Story = {
  name: '編集状態',
  args: {
    userSettingsFormProps: {
      ...meta.args.userSettingsFormProps,
      initialValues: {
        familyName: '山田',
        givenName: '太郎',
        email: 'taro.yamada@example.com',
        birthday: { year: '1996', month: '11', day: '11' },
      },
    },
  },
  play: async (args) => {
    await playOpen(args);
    await playFillAll(args);
  },
};

export const Filled: Story = {
  name: 'すべて入力済み',
  play: async (args) => {
    await playOpen(args);
    await playFillAll(args);
  },
};

export const Failed: Story = {
  name: '失敗',
  args: {
    userSettingsFormProps: {
      updateUserSettings: async () => {
        await new Promise((resolve) => setTimeout(resolve, 500));
        return { success: false, reason: '処理がタイムアウトしました。' };
      },
    },
  },
  play: async (args) => {
    await playOpen(args);
    await playFillAll(args);
    await playSubmit(args);
  },
};

export const Loading: Story = {
  name: 'ローディング',
  args: {
    UserSettingsForm: () => {
      throw new Promise((resolve) => {
        resolve(true);
      });
    },
  },
  play: playOpen,
};

export const EmptyValidation: Story = {
  name: '未入力のバリデーションエラー',
  play: async (args) => {
    await playOpen(args);
    await playSubmit(args);
  },
};

export const InvalidBirthdayValidation: Story = {
  name: '誕生日に不正な日付を入力したときのバリデーションエラー',
  play: async (args) => {
    await playOpen(args);
    await playFillName(args);
    await playFillEmail(args);
    await playFillInvalidBirthday(args);
    await playSubmit(args);
  },
};
親コンポーネント(UserSettingsFormDialog)のPresnetaion全体像
UserSettingsFormDialog.tsx
import { Button, type ButtonProps } from '@/components/ui/Button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from '@/components/ui/Dialog';

import { Suspense, useCallback, useState } from 'react';
import {
  UserSettingsForm as UserSettingsFormPresenter,
  UserSettingsFormProps as UserSettingsFormPresenterProps,
} from './UserSettingsForm/UserSettingsForm';
import {
  UserSettingsForm as UserSettingsFormContainer,
  UserSettingsFormProps as UserSettingsFormContainerProps,
} from './UserSettingsForm/UserSettingsForm.container';
import { useDisclosure } from '@/hooks/useDisclosure';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert';
import { AlertCircle } from 'lucide-react';

export type UserSettingsFormDialogProps = {
  invokeButtonProps?: ButtonProps;
  UserSettingsForm?: typeof UserSettingsFormPresenter;
  userSettingsFormProps?: StorybookProps<UserSettingsFormContainerProps, UserSettingsFormPresenterProps>;
};

type UserSettingsFormDialogState = {
  status: 'idle' | 'loading' | 'error';
  errorMessage: string | undefined;
};

const initialState: UserSettingsFormDialogState = {
  status: 'idle',
  errorMessage: undefined,
};

export function UserSettingsFormDialog({
  invokeButtonProps,
  UserSettingsForm = UserSettingsFormPresenter,
  userSettingsFormProps,
}: UserSettingsFormDialogProps) {
  const [opened, { open, close, set }] = useDisclosure();
  const [state, setState] = useState(initialState);

  const handleValid: UserSettingsFormPresenterProps['onValid'] = useCallback(
    async ({ values, updateUserSettings }) => {
      setState((prev) => ({
        ...prev,
        status: 'loading',
      }));
      const result = await updateUserSettings(values);
      if (result.success) {
        close();
        setState(initialState);
      } else {
        setState((prev) => ({
          ...prev,
          status: 'error',
          errorMessage: result.reason ?? '原因不明のエラーです。',
        }));
      }
    },
    [close],
  );

  return (
    <Dialog open={opened} onOpenChange={set}>
      <DialogTrigger asChild>
        <Button onClick={open} {...invokeButtonProps}>
          設定する
        </Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>ユーザ設定</DialogTitle>
        </DialogHeader>
        <div>
          {state.status === 'error' ? (
            <Alert variant="destructive" aria-labelledby="error-alert-title" aria-describedby="error-alert-description">
              <AlertCircle className="h-4 w-4" />
              <AlertTitle id="error-alert-title">ユーザ設定に失敗しました</AlertTitle>
              {state.errorMessage ? (
                <AlertDescription id="error-alert-description">{state.errorMessage}</AlertDescription>
              ) : null}
            </Alert>
          ) : null}
          <Suspense fallback={<div className="text-center">読み込み中...</div>}>
            {userSettingsFormProps ? (
              <UserSettingsForm {...userSettingsFormProps} onValid={handleValid} />
            ) : (
              <UserSettingsFormContainer onValid={handleValid} />
            )}
          </Suspense>
        </div>
        {state.status !== 'loading' ? <DialogClose /> : null}
      </DialogContent>
    </Dialog>
  );
}
子コンポーネント(UserSettingsForm)のContainer全体像
UserSettingsForm.container.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { UserSettingsForm as Presenter, type UserSettingsFormProps as PresenterProps } from './UserSettingsForm';
import { useUpdateUserMutation, userQueries } from '@/queries/user';
import { selectUpdateUserRequest, selectUserSettingsForm } from './UserSettingsForm.selector';
import { useCallback } from 'react';
import { useUserSettingsFormDialogContainerContext } from '../UserSettingsFormDialog.container.context';

export type UserSettingsFormProps = Pick<PresenterProps, 'onValid'>;

export function UserSettingsForm({ onValid }: UserSettingsFormProps) {
  const { userId } = useUserSettingsFormDialogContainerContext();
  const { data: initialValues } = useSuspenseQuery({
    ...userQueries.details({ userId }),
    select: selectUserSettingsForm,
  });
  const { mutateAsync: updateUser } = useUpdateUserMutation({ userId });

  return (
    <Presenter
      initialValues={initialValues}
      onValid={onValid}
      updateUserSettings={useCallback(
        async (values) => {
          try {
            const request = selectUpdateUserRequest(values);
            await updateUser(request);
            return {
              success: true,
            };
          } catch (e) {
            const reason = e instanceof Error ? e.message : undefined;
            return { success: false, reason };
          }
        },
        [updateUser],
      )}
    />
  );
}
子コンポーネント(UserSettingsForm)のPresentation全体像
UserSettingsForm.tsx
import { type UserSettingsForm, userSettingsForm, userSettingsFormDefault } from './UserSettingsForm.schema';
import { useForm } from 'react-hook-form';
import { valibotResolver } from '@hookform/resolvers/valibot';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
import { useDateSelectOptions } from '@/hooks/useDateSelectOptions';

export type UserSettingsFormProps = {
  initialValues?: UserSettingsForm;
  updateUserSettings: (values: UserSettingsForm) => Promise<MutateResult>;
  onValid: (params: {
    values: UserSettingsForm;
    updateUserSettings: UserSettingsFormProps['updateUserSettings'];
  }) => Promise<void>;
};

export function UserSettingsForm({ initialValues, onValid, updateUserSettings }: UserSettingsFormProps) {
  const form = useForm<UserSettingsForm>({
    values: initialValues ?? userSettingsFormDefault,
    resolver: valibotResolver(userSettingsForm),
  });
  const { yearOptions, monthOptions, dayOptions } = useDateSelectOptions();

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit((values) => {
          return onValid({ values, updateUserSettings });
        })}
      >
        <div className="py-4 space-y-2">
          <div className="flex gap-2">
            <FormField
              control={form.control}
              name="familyName"
              render={({ field }) => (
                <FormItem className="flex-1">
                  <FormLabel></FormLabel>
                  <FormControl>
                    <Input {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="givenName"
              render={({ field }) => (
                <FormItem className="flex-1">
                  <FormLabel></FormLabel>
                  <FormControl>
                    <Input {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メールアドレス</FormLabel>
                <FormControl>
                  <Input {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="birthday"
            render={({ fieldState: { error } }) => (
              <fieldset className="text-sm" aria-describedby="birthday-error-message">
                <legend data-invalid={!!error} className="data-[invalid='true']:text-destructive">
                  生年月日
                </legend>
                <div className="flex gap-2 mt-2">
                  <FormField
                    control={form.control}
                    name="birthday.year"
                    render={({ field }) => (
                      <FormItem className="flex-1">
                        <Select
                          onValueChange={(value) => {
                            field.onChange(value);
                            if (form.formState.isSubmitted) {
                              void form.trigger('birthday');
                            }
                          }}
                          defaultValue={field.value}
                        >
                          <FormControl>
                            <SelectTrigger aria-label="">
                              <SelectValue placeholder="" />
                            </SelectTrigger>
                          </FormControl>
                          <SelectContent aria-label="">
                            {yearOptions.map((option) => (
                              <SelectItem key={option.value} value={option.value}>
                                {option.label}
                              </SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                  <FormField
                    control={form.control}
                    name="birthday.month"
                    render={({ field }) => (
                      <FormItem className="flex-1">
                        <Select
                          onValueChange={(value) => {
                            field.onChange(value);
                            if (form.formState.isSubmitted) {
                              void form.trigger('birthday');
                            }
                          }}
                          defaultValue={field.value}
                        >
                          <FormControl>
                            <SelectTrigger aria-label="">
                              <SelectValue placeholder="" />
                            </SelectTrigger>
                          </FormControl>
                          <SelectContent aria-label="">
                            {monthOptions.map((option) => (
                              <SelectItem key={option.value} value={option.value}>
                                {option.label}
                              </SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                  <FormField
                    control={form.control}
                    name="birthday.day"
                    render={({ field }) => (
                      <FormItem className="flex-1">
                        <Select
                          onValueChange={(value) => {
                            field.onChange(value);
                            if (form.formState.isSubmitted) {
                              void form.trigger('birthday');
                            }
                          }}
                          defaultValue={field.value}
                        >
                          <FormControl>
                            <SelectTrigger aria-label="">
                              <SelectValue placeholder="" />
                            </SelectTrigger>
                          </FormControl>
                          <SelectContent aria-label="">
                            {dayOptions.map((option) => (
                              <SelectItem key={option.value} value={option.value}>
                                {option.label}
                              </SelectItem>
                            ))}
                          </SelectContent>
                        </Select>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                </div>
                {(() => {
                  const birthdayError = error?.message ?? error?.root?.message;
                  if (!birthdayError) {
                    return null;
                  }
                  return (
                    <p id="birthday-error-message" className="text-sm font-medium text-destructive mt-2">
                      {birthdayError}
                    </p>
                  );
                })()}
              </fieldset>
            )}
          />
        </div>
        <div className="text-right">
          <Button type="submit" isLoading={form.formState.isSubmitting}>
            保存
          </Button>
        </div>
      </form>
    </Form>
  );
}

関連記事紹介: 親子間が疎結合なときは子コンポーネントをReactNodeで定義できる

この記事の題材で扱ったユーザ情報設定ダイアログは、親子間が密結合しているときの例でした。
ユーザ情報設定ダイアログでは、UserSettingsForm?: typeof UserSettingsFormPresenter; のようにFunctinal Componentの形式で子コンポーネントを受け取るPropsを定義しています。レンダリングの中でC/Pの分岐をすることで、親Presentationで定義した値を子コンポーネントに渡すことができました。

一方で、親子間が疎結合なときは、ReactNodeを使って子コンポーネントをStorybook上で注入する手法を選択できます。詳細は、以下の記事でわかりやすく紹介されています。

https://quramy.medium.com/react-server-component-のテストと-container-presentation-separation-7da455d66576

参考

この記事に掲載しているサンプルコードは次のリポジトリで公開しています。全体像を把握したい方は是非ご覧になってください。

https://github.com/YTakahashii/frontend-articles-code/tree/main/apps/lightweight-storybook/src/components/usecases/user/UserSettingsFormDialog

Discussion