Reactの状態を、必要な場所にだけ置く

宣言的UIの考え方をもとに、Reactの状態を必要最小限に保ち、影響範囲を小さくする方法を考えます。

Published
2024年3月25日
Read in English

入力イベントのたびに全項目を確認して、送信ボタンの disabled を書き換える。Reactを使っていても、状態の置き方を間違えると、これに近いコードになります。状態が増え、遠くからも更新できるようになると、変更の理由も影響も追えなくなります。

宣言的UIという考え方を手がかりに、状態の置き方を考えます。

命令型UIと宣言型UI

Reactでは、状態に対してUIがどう見え、どう振る舞うかを宣言します。これに対し、ユーザー操作やネットワークレスポンスのたびにUIを直接操作する方法が命令的なアプローチです。

命令型UIについて

命令型UIでは、イベントハンドラーがコンポーネントの見た目や振る舞いを直接変更します。

imperative flow

入力フォームと送信ボタンがあり、初期状態ではボタンが無効だとします。入力イベントのたびにすべての項目を確認し、DOMを操作してボタンを有効にします。送信時には、多重送信を防ぐため再びボタンを無効にします。このように、イベントから表示を直接変更するコードが命令的です。

宣言型UIについて

宣言型UIでも、始まりはユーザー操作やネットワークレスポンスです。ただし、イベントハンドラーはUIを直接操作せず状態を更新します。コンポーネントは、その状態から現在の表示を決定します。

同じフォームなら、各入力値と送信中かどうかを状態として持ちます。入力イベントが変えるのは値、送信イベントが変えるのは送信状態です。ボタンの有効・無効は、そこから計算します。ハンドラーはUIを直接動かさず、表示を決めるための事実だけを更新します。

declarative flow

宣言型UIを意識する

宣言型UIでは、イベントハンドラーが状態を更新します。「この状態なら、こう表示する」という対応はコンポーネントの責任です。フォームの入力が完了したらボタンの色を変える実装を考えてみます。

declarative UI button event flow

フォームの入力を状態として持つなら、入力ハンドラーの責務は値の更新だけです。ボタンは状態を受け取り、自分の見た目と振る舞いを決めます。イベントと表示の間に状態を挟むことで、各ハンドラーが値の更新とボタン操作を兼ねる実装より、入力項目が増えても変更箇所を絞れます。

コードが読みにくいときには

宣言的に書いていても、状態が増えればコードは読みにくくなります。不要な値まで状態として持つ、1つの状態を多くのコンポーネントから参照・更新する、更新処理そのものが複雑になる、といったケースです。私は次の3点を意識しています。

  • 状態を必要最小限にする
  • できるだけ末端に状態を保持する
  • 状態変更するハンドラを返す

状態を必要最小限にする

値Aから計算できる値Bがあるとします。AとBの両方を状態にすると、次のようなコードになります。

const [a, setA] = useState();
const [b, setB] = useState();

useEffect(() => {
  const res = greetingLogic(a);
  setB(res);
}, [a]);

Bは独立した状態ではなく、Aが変わるたびに再計算すればよい派生値です。Aの更新で再レンダーされるため、Bはレンダー中に計算できます。

const [a, setA] = useState();
const b = greetingLogic(a);

表示に使う値をすべて状態にするのではなく、時間の経過や操作によって独立して変わる、必要最小限の値だけを状態にします。ほかの状態やpropsから計算できる値がないか確認すると、同期ずれの可能性も減らせます。

できるだけ末端に状態を保持する

親コンポーネントが持つ状態を、子へpropsとして渡すケースを考えます。次の例では、状態を使うDまでBとCを経由します。いわゆるprop drillingです。

BとCが値を中継するだけでも、そのpropsは各コンポーネントのAPIに含まれます。セッターまで渡せば、中間のコンポーネントからも状態を変更できるようになり、影響範囲はさらに広がります。

const A = () => {
  const [state, setState] = useState();
  return <B state={state} />;
};

const B = ({ state }) => <C state={state} />;
const C = ({ state }) => <D state={state} />;

大切なのは、状態がどこで、なぜ変わるのかを追いやすくすることです。不要な中間層を減らし、状態を必要なコンポーネントの近くへ置きます。複数の離れた場所で同じ値が必要なら、コンポーネント構成の見直しやContext、要件に合う状態管理ライブラリを検討します。prop drillingを避けるためだけに、すべての状態をグローバルへ移す必要はありません。

セッターではなくイベントを渡す

ある処理の状態に応じて、別のボタンの表示も変えたいケースを考えます。状態のセッターを子へ渡す代わりに、親が処理の意味を表すイベントを公開します。

const Parent = () => {
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    setLoading(true);
    try {
      await doSomething();
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <Component onSubmit={handleSubmit} />
      <Button loading={loading} />
    </>
  );
};

const Component = ({ onSubmit }) => {
  return (
    <div>
      <Something />
      <button onClick={onSubmit}>do something</button>
    </div>
  );
};

この例では、loading を別のコンポーネントも参照します。子へセッターをそのまま渡すと、親の状態をどのような理由でも変更できてしまいます。代わりに、意味のあるイベントハンドラーを親で定義して渡すと、状態と更新理由を近くに置けます。ロジックが増えたらCustom Hookへ切り出すことで、独立してテストしやすくなります。

さいごに

状態が変わると、その状態を持つコンポーネントと配下が再レンダーされます。どこから更新され、どこへ影響するのかを追えなくなると、Reactアプリは急に複雑になります。

冒頭のボタンの例に戻ると、宣言的に書くだけでは足りません。状態をどこに、どれだけ持つかまで決めて、はじめて変更の理由と影響する範囲が見えるようになります。

参考文献