header

テストしやすいReactコンポーネントの設計をサポートするライブラリを作った

課題

現時点のReactでは関数コンポーネントが主流です。 クラスコンポーネントよりもシンプルでテストしやすいためです。

しかし、複雑なロジックを持つ場合は関数コンポーネントであっても難しいです。 複数の状態を持っていたり、useEffect が絡んでいたりすると特に難しくなります。 それらはコンポーネント内で生成され、直接テストすることができないからです。

解決策

課題の根本的な原因は、画面上に表示されるビューと、状態やその更新を行うロジックが密に結合しているためです。 これらを分離し、別々にテストすることで解決できます。 そのアイディアを実現する方法はいくつかあります。

Hooksの導入前は、ビューを担当する Presentational Components とロジックを担当する Container Components に分ける方法が有名でした。

Hooksの導入後はロジック部分を Custom Hooks に置き換えられます。

react-container

https://github.com/Foo-x/react-container

react-containerテスタブルなReact Componentの書き方 で紹介されている設計をサポートするライブラリです。

使用例

import { Container, UseHooks, View } from '@foo-x/react-container';
import { useCallback, useState } from 'react';

type Props = {
  defaultValue: number;
  label: string;
};

type HooksResult = {
  value: number;
  increment: () => void;
  decrement: () => void;
};

export const useHooks: UseHooks<Props, HooksResult> = ({
  props: { defaultValue },
}) => {
  const [value, setValue] = useState(defaultValue);
  return {
    value,
    increment: useCallback(() => {
      setValue((prev) => prev + 1);
    }, []),
    decrement: useCallback(() => {
      setValue((prev) => prev - 1);
    }, []),
  };
};

export const view: View<Props, HooksResult> = ({
  props: { label },
  hooksResult: { value, increment, decrement },
}) => {
  return (
    <div>
      <h2>{label}</h2>
      <div style={{ display: 'flex', gap: '1rem' }}>
        <button type='button' onClick={decrement}>
          -
        </button>
        {value}
        <button type='button' onClick={increment}>
          +
        </button>
      </div>
    </div>
  );
};

const Counter = Container({ useHooks, view });

export default Counter

Props はコンポーネントの引数です。

HooksResult は Custom Hooks の戻り値です。

useHooks は Custom Hooks です。 Props を受け取って HooksResult を返します。

view はビューを生成する関数です。 PropsHooksResult を受け取ってReactの要素を返します。

ライブラリが提供している ContaineruseHooksview を結合することでコンポーネントを生成できます。

ビューとロジックが分かれたので別々にテストできるようになりました。 それぞれ以下の点が改善されます。

  • ビュー
    • 引数にのみ依存するようになったのでテストしやすくなった
  • ロジック
    • 直接テストできなかったものができるようになった

react-tea

https://github.com/Foo-x/react-tea

The Elm Architecture (TEA) というアーキテクチャがあります。 詳細は省きますが、まさに状態・ロジック・ビューを分けるものです。

react-tea はTEAによるReactの設計をサポートするライブラリです。

使用例

import {
  Cmd,
  exhaustiveCheck,
  Init,
  Sub,
  Tea,
  Update,
  View,
} from '@foo-x/react-tea';

type Model = number;

type Msg = 'increment' | 'decrement';

type Props = {
  defaultValue: number;
};

export const init: Init<Model, Msg, Props> = ({ props }) => [
  props.defaultValue,
  Cmd.none(),
];

export const update: Update<Model, Msg, Props> = ({ model, msg }) => {
  switch (msg) {
    case 'increment':
      return [model + 1, Cmd.none()];

    case 'decrement':
      return [model - 1, Cmd.none()];

    default:
      return exhaustiveCheck(msg);
  }
};

export const subscriptions: Sub<Model, Msg, Props> = Sub.none();

export const view: View<Model, Msg, Props> = ({ model, dispatch, props }) => {
  return (
    <div>
      <h2>Simple Counter</h2>
      <h3>default: {props.defaultValue}</h3>
      <div style={{ display: 'flex', gap: '1rem' }}>
        <button
          type='button'
          onClick={() => {
            dispatch('decrement');
          }}
        >
          -
        </button>
        {model}
        <button
          type='button'
          onClick={() => {
            dispatch('increment');
          }}
        >
          +
        </button>
      </div>
    </div>
  );
};

const SimpleCounter = Tea({ init, update, subscriptions, view });

export default SimpleCounter;

Model は状態です。

Msg は状態を更新するメッセージです。

Props はコンポーネントの引数です。

init は状態を初期化する関数です。 Props を受け取って初期状態とコマンドを返します。 コマンドとは、非同期でメッセージを送信する処理のことです。

update は状態の更新を行う関数です。 PropsModelMsg を受け取って新しい状態とコマンドを返します。 Hooksの useReducer に渡すreducerに似た関数ですが、コマンドによって非同期な状態の更新も扱えます。

subscriptions はイベントリスナーです。 Hooksの useEffect に相当します。

view はビューを生成する関数です。 PropsModeldispatch を受け取ってReactの要素を返します。 dispatch はメッセージを送信する関数です。

ライブラリが提供している Teainitupdatesubscriptionsview を結合することでコンポーネントを生成できます。 react-container と同様に useHooks でHooksを使用することもできます。 詳しくは リポジトリ のドキュメントを参照してください。

まとめ

ほとんどのケースでは react-container で十分です。 しかし、特に複雑な状態・ロジックを持つコンポーネントの場合は、ぜひ react-tea を使ってみてください。 ロジックがより細かい粒度で分かれるので、さらにテストしやすくなるはずです。