Storybookでcontrolledなコンポーネントを操作可能にしつつ引数をモックする

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に設定したisSelecteduseStateフックに渡す。これによって、argsから初期値を制御できるようになる。別のstoryを作成する際に次のように活用できる。

export const SelectedByDefault = {
  args: {
    isSelected: true,
  },
} satisfies Story;

最後に、argTypesからisSelectedcontrolfalseにする。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>
  );
}

その他の使用例は同リポジトリから参照できる