Storybookにおいて、Reactコンポーネントを使ったstoryを作成する際、単なるボタンのようなシンプルなコンポーネントであればcomponent
プロパティに指定するだけで良い。
なおこの記事での解説は、Storybookのバージョン8.1.1に基づいたものである。
Button.tsx
:
import { MouseEvent, ReactNode } from 'react';
export interface ButtonProps {
children?: ReactNode | undefined;
onClick?: ((event: MouseEvent<HTMLButtonElement>) => void) | undefined;
}
export function Button({ children, onClick }: ButtonProps) {
return (
<button type="button" className="button" onClick={onClick}>
{children}
</button>
);
}
Button.stories.tsx
:
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Button } from './Button';
const meta = {
title: 'Button',
component: Button,
args: {
onClick: fn(),
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary = {
args: {
children: 'Click me',
},
} satisfies Story;
このように記述することで、コンポーネントにargsが渡されたうえで描画される。疑似コードで表現すれば次のようになる。
function Primary() {
const args = {
children: 'Click me',
onClick: fn(),
};
return <Button {...args} />;
}
一方、controlledなコンポーネントのstoryを作成する場合、そのコンポーネントの状態を外部から制御するための実装が必要になる。たとえば次のように。
ToggleButton.tsx
:
import { ReactNode, useCallback } from 'react';
export interface ToggleButtonProps {
isSelected: boolean;
children?: ReactNode | undefined;
onChange: (isSelected: boolean) => void;
}
export function ToggleButton({ isSelected, children, onChange }: ToggleButtonProps) {
const handleClick = useCallback(() => {
onChange(!isSelected);
}, [isSelected, onChange]);
return (
<button type="button" className="toggle-button" aria-pressed={isSelected} onClick={handleClick}>
{children}
</button>
);
}
ToggleButton.stories.tsx
:
import { useState } from 'react';
import { ToggleButton } from './ToggleButton';
function Primary() {
const [isSelected, setSelected] = useState(false);
return (
<ToggleButton isSelected={isSelected} onChange={setSelected}>
Toogle
</ToggleButton>
);
}
render
メソッドの実装
これをstoryとして表現するには、component
プロパティにコンポーネントを指定するだけでは不十分である。そこでこの描画方法をカスタマイズするために、render
メソッドを併せて実装する必要がある。
ToggleButton.stories.tsx
:
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ToggleButton } from './ToggleButton';
import { useState } from 'react';
const meta = {
title: 'ToggleButton',
component: ToggleButton,
args: {
isSelected: false,
onChange: fn(),
},
render: function Render(args) {
const [isSelected, setSelected] = useState(false);
return <ToggleButton {...args} isSelected={isSelected} onChange={setSelected} />;
},
} satisfies Meta<typeof ToggleButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary = {
args: {
children: 'Toggle',
},
} satisfies Story;
metadata(meta
)として指定された値はstoryに継承されるため、自ずとPrimary storyでは同じrender
メソッドが再利用される。ちなみにrender
メソッドにRender
という名前で関数宣言を記述しているのは、eslint-plugin-react-hooksのreact-hooks/rules-of-hooks
ルールとの競合を回避するためだ。
これによって、controlledなコンポーネントを操作可能なstoryをを作成できる。ただしこの実装では、Storybookのcontrolsからargsの値を制御できなくなってしまう。
useArgs
フックの使用
そのようなケースに対応すべく、argsの値を操作するuseArgs
フックがStorybookによって提供されている。これを利用することで次のような実装ができる。
ToggleButton.stories.tsx
:
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ToggleButton } from './ToggleButton';
const meta = {
title: 'ToggleButton',
component: ToggleButton,
args: {
isSelected: false,
onChange: fn(),
},
render: function Render(args) {
const [{ isSelected }, updateArgs] = useArgs();
const handleChange = useCallback<ToggleButtonProps['onChange']>(
(isSelected) => {
updateArgs({ isSelected });
},
[updateArgs],
);
return <ToggleButton {...args} isSelected={isSelected} onChange={handleChange} />;
},
} satisfies Meta<typeof ToggleButton>;
残る問題は、argsに設定されているonChange
が呼び出されていないことである。onChange
に渡されているfn()
はメソッドをモックするための機能であり、Jestにあるモック関数に似たものだ。これを用いることで、呼び出しのたびにStorybookのActionsパネルにログを表示したり、interaction testsにて呼び出し回数を調べたりできる。storyをより有用なものにするには、これが機能している方が良いだろう。
そんなわけで、このモックの呼び出しを有効にするには、次のように実装する。
ToggleButton.stories.tsx
:
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { useCallback } from 'react';
import { ToggleButton, ToggleButtonProps } from './ToggleButton';
const meta = {
title: 'ToggleButton',
component: ToggleButton,
args: {
isSelected: false,
onChange: fn(),
},
render: function Render(args) {
const [{ isSelected }, updateArgs] = useArgs();
const handleChange = useCallback<ToggleButtonProps['onChange']>(
(...a) => {
args.onChange(...a);
const [isSelected] = a;
updateArgs({ isSelected });
},
[args, updateArgs],
);
return <ToggleButton {...args} isSelected={isSelected} onChange={handleChange} />;
},
} satisfies Meta<typeof ToggleButton>;
onChange
propの呼び出しタイミングでargsのonChange
メソッドとupdateArgs
フックが同時に呼び出されるようにする。それにより、argsの値を制御可能にしつつ、メソッドの呼び出しのたびにログが表示されるようにできる。
useArgs
フックの問題
しかし、useArgs
フックには、同一ページ内の複数箇所で呼び出されると正しく機能しないという不具合がある。したがって、Autodocsによって生成されるドキュメントページなどではコンポーネントが操作不能になってしまうため、都合が悪い。
そのため、useArgs
フックを使わないパターンの実装も紹介しておく。次のようになる。
ToggleButton.stories.tsx
:
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { useCallback } from 'react';
import { ToggleButton, ToggleButtonProps } from './ToggleButton';
const meta = {
title: 'ToggleButton',
component: ToggleButton,
tags: ['autodocs'],
argTypes: {
isSelected: {
control: false,
},
},
args: {
isSelected: false,
onChange: fn(),
},
render: function Render(args) {
const [isSelected, setSelected] = useState(args.isSelected);
const handleChange = useCallback<ToggleButtonProps['onChange']>(
(...a) => {
args.onChange(...a);
setSelected(...a);
},
[args],
);
return <ToggleButton {...args} isSelected={isSelected} onChange={handleChange} />;
},
} satisfies Meta<typeof ToggleButton>;
まず、useArgs
フックの代わりに、前述と同様のuseState
フックを使うようにする。次に、argsに設定したisSelected
をuseState
フックに渡す。これによって、argsから初期値を制御できるようになる。別のstoryを作成する際に次のように活用できる。
export const SelectedByDefault = {
args: {
isSelected: true,
},
} satisfies Story;
最後に、argTypesからisSelected
のcontrol
をfalse
にする。useArgs
フックを使用しないとcontrolからの制御はできないので、control自体を非表示にしてしまう。
controlledとuncontrolledの両方に対応したコンポーネントを作成する
とはいえ、そもそもコンポーネントがuncontrolledな状態にも対応できるように実装されていれば、通常利用する際にも便利だし、Storybookでの扱いも簡単になる。controlledとuncontrolledの両方に対応するコンポーネントを作成するには少しコツがいるが、既存のライブラリなどを利用すれば容易に実現できる。
React Spectrumで利用されている@react-stately/utilsでは、そのためのuseControlledState
フックが提供されており、次のような実装ができる。
ToggleButton.tsx
:
import { ReactNode, useCallback } from 'react';
import { useControlledState } from '@react-stately/utils';
export interface ToggleButtonProps {
isSelected?: boolean | undefined;
defaultSelected?: boolean | undefined;
children?: ReactNode | undefined;
onChange?: ((isSelected: boolean) => void) | undefined;
}
export function ToggleButton({ children, ...props }: ToggleButtonProps) {
const [isSelected, setSelected] = useControlledState(
props.isSelected,
props.defaultSelected || false,
props.onChange,
);
const handleClick = useCallback(() => {
setSelected(!isSelected);
}, [isSelected, setSelected]);
return (
<button type="button" className="toggle-button" aria-pressed={isSelected} onClick={handleClick}>
{children}
</button>
);
}