ほとんどのReactのコードベースを開けば、同じような癖を目にするでしょう。値を追跡する必要があると、開発者はすぐに useState に手を伸ばします。カウンターが必要? useState。一時的な入力値? useState。モーダルを切り替えるための boolean? useState。やがて、一つのコンポーネントが十数個もの個別の hook を抱え、それぞれが、レンダリングをまたいで保持する必要があるかないか分からない、ごくわずかなデータを管理するようになります。その結果、コードは煩雑になり、不要な再レンダリングが発生し、状態がコンポーネント全体に小銭のように散らばってしまいます。
その習慣は理解できます。useState は多くの人が最初に学ぶ hook であり、実際に動作します。しかし、「動作すること」と「適切であること」は別物です。あらゆるデータをリアクティブな state として扱うと、コンポーネントが成長したときに初めて表面化する問題を引き起こします。
値が変わるからといって、必ずしも State が必要だとは限りません
時間が経つにつれて変化する変数すべてが useState に入るべきだとは限りません。中には、すでに持っている他の何かの結果に過ぎない値もあります。単に firstName と lastName を結合しただけのユーザーのフルネームを state に保存してしまうと、情報のソースが二重になってしまいます。親コンポーネントの再レンダリングによって firstName が更新されたとき、あなたの fullName state は、同期するための別の effect を実行するまで古いまま残ってしまいます。同期のための effect は必要ありません。必要なのは「派生値(derived value)」です。
const fullName = `${firstName} ${lastName}`;
レンダリング中に計算してください。もしその計算コストが高い場合は、メモ化してください。ただし、ユーザーがそのフルネームを構成要素とは独立して編集できる場合を除き、それ自体に useState hook を持たせてはいけません。
同じルールは、フィルタリングされたリストにも適用されます。allItems と filteredItems の両方を state に保持していると、メンテナンスすべき対象が二倍になってしまいます。フィルタリングはレンダリング中に行ってください。ソースとなる配列とフィルタリング用のテキストを state に保持し、表示するリストを派生させます。これにより、フィルタリングされたリストがソースと同期しなくなることを防げます。
再レンダリングをトリガーすべきではない値もあります
useState は、何かが変更され、DOM の更新が必要かもしれないことを React に伝えるために存在します。値が変わっても UI のどの部分もその変更を気にしないのであれば、useRef の方が優れたツールです。
タイマーやインターバルは典型的な例です。setInterval の ID を state に保存すると、ユーザーはインターバル ID を見ることはできないにもかかわらず、タイマーを開始または停止するたびに再レンダリングが発生します。ref であれば、React に通知することなくその値を保持できます。同じロジックは、以前の props の追跡、描画前の DOM ノードの測定、またはカスタム hook 用の最新のコールバックの保存にも適用されます。「この値は画面に表示される必要があるか?」と自問してみてください。もし答えが「いいえ」なら、おそらく useState は必要ありません。
DOM ノード自体も ref に属すべきです。DOM 要素を state に保存することもできますが、そうすると ref コールバックが実行された後に再レンダリングがトリガーされます。ほとんどの場合、ノードが必要なのは命令的なメソッドや測定のためであり、それを異なる方法でレンダリングするためではありません。
Boolean の罠
すべてのフラグに個別の hook を割り当てると、関連する UI の関心事が拡散しがちです。isLoading、isError、isSuccess が3つの別々の boolean として定義されているコンポーネントをよく目にします。問題は、これら3つの状態は独立していないということです。もし isLoading と isSuccess が両方 true であれば、UI は不可能な状態にありますが、TypeScript も React もそれをそのままレンダリングさせてしまいます。
関連する状態をグループ化することで、こうした無効な組み合わせを防ぐことができます。3つの boolean の代わりに、単一のステータス文字列('idle', 'loading', 'success', または 'error')を追跡してください。一度にアクティブになれるのは一つだけなので、型レベルで不可能な状態を排除できます。データがより複雑な場合は、判別可能な共用体(discriminated union)を持つオブジェクトを使うと、さらに整理されます。同じイベントハンドラー内で複数の useState 呼び出しを更新していることに気づいたら、それはそれらの値がセットであるべきだという合図です。
別の useState ではなく、useReducer を使いましょう
state の更新が、まるでモグラ叩きのようなゲームになってしまう段階があります。一つの関数内で setA を呼び、次に setB を呼び、さらに条件に応じて setC を呼び出す、といった具合です。そのコードを読む次の開発者は、コンポーネントが実際に何をしているのかを理解するために、その一連のシーケンスを追跡しなければなりません。
ここで useReducer が真価を発揮します。useReducer は、より高度だから useState に代わるのではなく、ロジックがそれを求めているから useState に代わるのです。reducer は state がどのように変化するかを中央集約します。イベントハンドラーのあちこちに命令的な処理を散りばめる代わりに、意図を dispatch します:dispatch({ type: 'submitted' })。次にどのような state になるかは reducer が決定します。state のロジックは純粋関数(pure function)になるため、テストは極めて容易になります。また、すべての変更が追跡可能な action として残るため、デバッグも容易になります。
reducerを使うためにReduxが必要なわけではありません。3つ以上のstate変数が同時に更新される場合や、次のstateが前のstateに強く依存している場合、reducerを使うことでコンポーネントを劇的に簡素化できます。
Stateが実際に存在する場所
時として、問題はstateを「どのように」保存するかではなく、「どこに」保存するかにあることがあります。よくある間違いは、他の場所で必要になるかもしれないという理由だけで、単にstateを親コンポーネントへホイスティングしてしまうことです。もし特定のstateを一つの末端(leaf)コンポーネントしか使わないのであれば、そのままそこに置いておきましょう。これがcolocation(近接配置)であり、変更による影響範囲を抑えることにつながります。子コンポーネントが開いたことで親を再レンダリングさせてはいけません
