fetchに依存しない軽量なStorybookを構築するテクニック
こんにちは。この記事では、先日公開した軽量なStorybook駆動開発に関する記事の続編として、ネストしたContainer/Presentationコンポーネントに対して軽量なStorybookを構築する方法について紹介します。
軽量なStorybook
軽量なStorybookとは、Container/Presentation(C/P)パターンを活用してネットワークの関心を排除したStorybookのことを指しています。
APIモックツールを使用しないことでStorybookの実装容易性が向上することに加え、Portable Storiesを活用したテストの動作が安定するため、Storybook駆動開発の文化を醸成する手助けとなります。
詳細は前回の記事で紹介していますので、この記事と合わせてご覧ください。
ネストしたContainer/PresentationをStorybookでどう扱うか?
C/Pパターンがネストしていて親子関係が密結合の場合、軽量なStorybook駆動開発を支えるコンポーネント設計で紹介した内容がうまく適用できません。
単純な実装をしている場合、親PresentationのStoryを構築すると内部で子Containerがレンダリングされるため、APIモックが必要になってしまう問題が生じます。
Storybook: 親Presentation-> 子Container(ネットワーク処理の実行) -> 子Presentation
ネストしたC/Pの具体例:ユーザ情報設定ダイアログ
もう少し具体的な例でネストしたC/Pを考えていきましょう。以降の説明では、ユーザ情報設定ダイアログ(<UserSettingsFormDialog />
)の実装を題材に扱います。
ユーザ情報設定ダイアログ
ユーザ情報設定ダイアログは以下の要件を満たすものとします。(重要な一部を抜粋)
- 「設定する」ボタンの押下時にユーザ情報を取得してフォームの初期値を設定する
- 「保存」ボタンを押すと保存処理が実行される
- 保存中はダイアログ右上の閉じるボタンが非表示になる
- 保存に完了するとダイアログが閉じる
ユーザ情報設定ダイアログの実装方針
先の要件を踏まえてユーザ情報設定ダイアログを実装するとき、筆者は次の2つにコンポーネントを分割します。
-
<UserSettingsFormDialog />
: フォーム部分を除くダイアログ全体のUIと状態管理を担当する親コンポーネント -
<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コードのみで実装できています。
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
を取得しています。
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の定義
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;
}
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の実装例
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つを定義します。
- Functional Component型の子コンポーネント
- Containerで注入される子PresentationコンポーネントのProps
以下に実装例を示します。<Suspense />
内のコードに注目してください。userSettingsFormProps
が指定された場合はPresentationコンポーネントをレンダリングし、指定されてない場合はネットワーク処理を自律的に行うContainerコンポーネントをレンダリングします。
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で注入されるプロパティのみを返します。
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を構築するためです。
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
を使用してフォームを保存時の状態に応じたダイアログ状態の制御を実現しています。
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の全体像
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全体像
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全体像
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全体像
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上で注入する手法を選択できます。詳細は、以下の記事でわかりやすく紹介されています。
参考
この記事に掲載しているサンプルコードは次のリポジトリで公開しています。全体像を把握したい方は是非ご覧になってください。
Discussion