入力イベントのたびに全項目を確認して、送信ボタンの disabled を書き換える。Reactを使っていても、状態の置き方を間違えると、これに近いコードになります。状態が増え、遠くからも更新できるようになると、変更の理由も影響も追えなくなります。
宣言的UIという考え方を手がかりに、状態の置き方を考えます。
命令型UIと宣言型UI
Reactでは、状態に対してUIがどう見え、どう振る舞うかを宣言します。これに対し、ユーザー操作やネットワークレスポンスのたびにUIを直接操作する方法が命令的なアプローチです。
命令型UIについて
命令型UIでは、イベントハンドラーがコンポーネントの見た目や振る舞いを直接変更します。

入力フォームと送信ボタンがあり、初期状態ではボタンが無効だとします。入力イベントのたびにすべての項目を確認し、DOMを操作してボタンを有効にします。送信時には、多重送信を防ぐため再びボタンを無効にします。このように、イベントから表示を直接変更するコードが命令的です。
宣言型UIについて
宣言型UIでも、始まりはユーザー操作やネットワークレスポンスです。ただし、イベントハンドラーはUIを直接操作せず状態を更新します。コンポーネントは、その状態から現在の表示を決定します。
同じフォームなら、各入力値と送信中かどうかを状態として持ちます。入力イベントが変えるのは値、送信イベントが変えるのは送信状態です。ボタンの有効・無効は、そこから計算します。ハンドラーはUIを直接動かさず、表示を決めるための事実だけを更新します。

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

フォームの入力を状態として持つなら、入力ハンドラーの責務は値の更新だけです。ボタンは状態を受け取り、自分の見た目と振る舞いを決めます。イベントと表示の間に状態を挟むことで、各ハンドラーが値の更新とボタン操作を兼ねる実装より、入力項目が増えても変更箇所を絞れます。
コードが読みにくいときには
宣言的に書いていても、状態が増えればコードは読みにくくなります。不要な値まで状態として持つ、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アプリは急に複雑になります。
冒頭のボタンの例に戻ると、宣言的に書くだけでは足りません。状態をどこに、どれだけ持つかまで決めて、はじめて変更の理由と影響する範囲が見えるようになります。