React で状態をあつかう

by 37108 at 2024/03/25

React を利用してコードを書いていく上で状態は必ずあらわれます。 状態を丁寧に扱うことで綺麗なコードができあがります。雑に扱わないようにしましょう。 どのようにするべきか、React の考え方にならって私なりに考えてみました。

命令型UI と宣言型UI

React はコンポーネントの状態に基づいた、UIの見た目や動作を宣言的に記述する方針をとっています。 反対にユーザアクションやネットワークリクエストに応じてUIを直接的に操作するのが命令型UIに該当します。

命令型UI について

ユーザやネットワークリクエストなどに応じてイベントが発生します。そのイベントハンドラーが直接的にコンポーネントの見た目や振る舞いを変更するのが命令型UI と言われるものです。

imperative flow

例えば一連のフォームとサブミットボタンがあり、ボタンは初回表示時にはdisabled とします。 フォームの入力要素に文字列を打ち込むと、そのイベントのハンドリングで全ての入力が完了していることを確認します。それが終わり次第ハンドラが直接的にボタンを有効化するのが命令型のコードでしょう。また有効になったボタンを押下したらネットワークリクエストを投げるとします。そのイベントハンドリングで多重送信防止のために、ボタンを再度、直接無効にするのも同じようなコードでしょう。

宣言型UI について

命令型UIと始まりは同じです。ユーザやネットワークリクエストなどに応じてイベントが発生します。そのイベントハンドラでは状態を変更します。そしてコンポーネントが状態に応じて適切な表示になるのが、宣言型UI です。

例えば一連のフォームとサブミットボタンがあり、ボタンは初回表示時にはdisabled とします。そしてフォームで扱う入力の状態と、送信時にネットワークリクエストの状態がどうであるかという状態が追加されます。フォームの入力要素に文字列を打ち込むと、イベントを通じて状態を変更します。ハンドラー自体は直接ボタンの状態を変更しません。ボタンがフォームの入力状態が全て埋まっているかどうかを確認してボタンを有効化させます。また有効になったボタンを押下したらネットワークリクエストを投げるとします。そのイベントハンドリングでボタンの状態を変更することでボタンの表示が切り替わります。

declarative flow

宣言型UI を意識する

命令型UIではハンドラがどのような表示になるべきか直接変更するのに対して、宣言型UIのハンドラは状態を更新します(それ以外のこともできますが一旦置いておきます)。そしてコンポーネント側で「 どの状態だから表示がこうあるべき 」という決定に従い表示をします。例えばフォームの入力が完了したらボタンの色が変える実装を考えます。フォームの各要素は入力を受け付けるイベントハンドラが作成され、それらが入力の一連の処理を担います。

declarative UI button event flow

フォームの入力を状態として保持するばあい、入力の変更というイベントに対してハンドラは状態変更を責務とします。ボタンの表示変更はそのイベントの責務ではなく、ボタン自体が状態に応じて見た目と振る舞いを変えるという責務になります。それぞれの入力要素のハンドラが値の変更とボタンの色変更を担う命令型に比べてハンドラの役割は簡素になり、それぞれの入力で相互に状態を確認する必要がないため変更が容易(要素が増えるたびにそれぞれのハンドラでボタンの色変化のロジックを調整しなければならない)になります。つまり、イベントハンドラと要素の間に状態をおくことによってそれぞれの責務を明確にわけるのが重要なポイントです。

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

宣言型UI であることの利点について述べてきましたが、宣言型UIでも起こり得る問題についても記述していきます。 コードベースが大きくなるにつれて徐々に可読性が落ちていくことがあります。例えば不必要な内容まで状態にしたので管理するべき状態が膨らんだ、1つの状態をpropsとして多くのコンポーネントで参照と更新をしたので影響範囲が理解できなくなったり(とても長いバケツリレーとか…)、そもそも状態変更に含むべきロジックが複雑であったりするケースです。個人的に普段意識している対処法はこれらなので次はそれについて触れていきます。

状態を必要最小限にする

例えば値A に依存して変化する値B があるとします。このときA もB も状態として、下記のコードを書いているケースがたまにあります。

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

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

B は状態ではなくA の変更に応じて再計算が必要なだけの値です。 A の変化には再レンダーが必要なためA を状態とすると、Bは状態にせずともただ計算を入れるだけで良い値であることがわかります。

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

値によって表示が変わるから状態にするのではなく、それの変更によって直接的に再レンダーが起こりえる内容を状態としましょう。 つまり、どのような描写が必要であるかによって状態を定義します。次に定義した状態の中に 他の状態に依存して変化するだけの値 がないかを確認します。これによって必要最小限の状態で十分なUIを表現できるようになります。

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

ある親が状態を保持しており、それを子コンポーネントにprop で渡すとします。下記の例は過剰気味ではありますが、状態を利用したいコンポーネントD まで中間を通る形になっています。props のバケツリレーとよく言われますが、公式では props の穴掘り作業 (prop drilling) とよんでいるケースです。

重要なのは中間にあるコンポーネントB、C が単純にstate を受け渡しているだけであってもコード上ではそのstate を利用していることです。状態だけならprop の変更による再レンダーだけですが、セッター関数を渡すと状態の変更が可能なコンポーネントになります。

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

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

B とC の責務はD と同様に状態を扱うことを理解して「中間層では状態の参照と更新も行わない」などルールを決めて守れば1人で開発する分には問題は起こりにくいです。ただチームで開発すると本当に参照と更新を行なっていないか把握するのはコンポーネントを追わなくてはいけないので骨が折れます。仮に1個でもルールに従わないコードが通った時に影響範囲の特定と修正が困難になっていきます。

つまり、状態の変更理由を把握しやすい環境づくりが大事ということです。 今回の例のようなケースであれば不要な中間層を削除すれば良いです。親から直接必要なコンポーネントのみに状態を配る、例えばフォームを親要素として入力状態をフォームの各種セクションコンポーネントに配る具合に1階層くらいでとどめておくのが良いと思います。また数階層にわたって値を渡していきたいのであれば、React.Context の利用や、Redux、Recoilなどの状態を管理するためのライブラリの導入が検討できます。

状態変更するハンドラを返す

あるコンポーネントの処理状態に応じて表示を変えたいボタンがあるとします。 単純に状態を切り替えるだけであれば直接渡しても大きな問題はありません。

const Parent = () => {
  const [loading, setLoading] = useState(false);
  return (
    <>
      <Component setLoading={setLoading} />
      <Button loading={loading} />
    </>
  );
};

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

今回は loading 状態が他のコンポーネントでも参照されます。なのでイベントハンドリングで状態変更が起こるか、親が管理するのはコードを追う上で都合が良いです。宣言型UI であってもイベントハンドラの中身は命令なので状態変更が含まれるのであればそのロジックと実際の状態の位置が近しい方が読みやすいと私は思っているところもあります。 特に実際にイベントハンドリングを行うコンポーネント外の要素に対して何かしらの操作をする場合はより都合が良くなります。親コンポーネントでロジックが増えてきたのであれば、React Hook に切り出せばテスト含めて扱いやすくなるという利点も含んでいます。

さいごに

状態を変更すると再レンダーが起こります。どのコンポーネントやHooks が再レンダーを引き起こすかを理解できなくなるときに、React で書かれたプロジェクトは複雑になっていきます。だから「再レンダーを引き起こせる対象」と「再レンダーによって影響を受ける対象」を最小にすることだけでもきっとコードは読みやすくなります。

そしてイベントハンドラでの命令が膨らむ可能性も考慮して子コンポーネントに直接セッター関数を渡す代わりにイベントハンドラを渡して状態とイベントハンドラを近くに配置することでロジックを把握しやすくしましょう。

参考文献