こんにちは、ENECHANGE でエンジニアをしている清水です。
今年の7月に電力料金シミュレーション領域のチームへジョインしました。
当時、チームではフロントエンドの状態管理の複雑さを解消するため、 Jotai への移行が進められていました。
しかし、実際にコードを追うにつれ、根本的なつらさの原因は状態管理の手段ではなく「判定の所在」という構造そのものにあることに気づきました。
本記事では、5回の「なぜ」で真因に辿り着き、仕組みを足さずに判定の所在を変えるだけで解決した過程をご紹介します。
状態管理における課題
開発している電力料金シミュレーションツールは、ユーザーの選択に応じて条件が数珠つなぎに連鎖する画面です。
以下の図のように、ひとつの項目が確定すると、次に選べる内容が段階的に決まっていきます。
flowchart LR
A["郵便番号"] --> B["エリア"] --> C["電力会社"] --> D["プラン"] --> E["契約容量の単位<br/>(アンペア / kVA)"]
旧構造では、状態(入力値・サーバーデータ・計算結果)と判定ロジック(試算実行可否・表示制御)がすべて中央の Context に集中し、次の問題が発生していました。
- 状態の平坦化と影響範囲の肥大化: 複数ドメインの状態が1箇所に集約され、ある値を変えたときに何が壊れるか追跡できない
- 二重管理によるバグ: サーバーデータを中央ストアへ手動でミラーリングしていたため、状態ズレやフラグの誤上書きが発生。実際に開発中、通信中を表す
loadingフラグが後続の同期処理で上書きされ、ローディング表示が消えるバグが発生しました(リリース前に検知・修正済み) - 手動同期コードの乱立: 状態変化に応じた更新・リセット処理が各所に散らばり、機能追加のたびに書き足す必要があった
この構造は、当時の要件に対しては自然な選択でしたが、扱う条件と連動が増えるにつれて、中央に判定を集める構造の限界が見え始めていました。
同期の置き場所が変わっただけだった
これらの課題を解決するため Jotai へ移行し、依存関係を Atom のグラフとして一元管理することで、一定の見通しの良さを得ることができました。
しかし、コードを深く読み解くにつれ、ライブラリの変更だけでは根本的な問題が解決していないことに気づきました。
- 同期処理の潜在化: 書き込み専用 Atom(write-only atom)の内部に移動したことで見えにくくなったものの、同期処理の構造自体は残っていた
- 中央判定ロジックの残存: API ごとの完了判定は各ハンドルに分散されていたものの、ドメイン固有の業務例外(プラン種別による特殊な完了条件など)が共通の集約関数に直接書き込まれ、「誰が判定するか」という構造上の課題は一部で手つかずのままだった
同期の置き場所は変わりましたが、同期が必要である理由は変わっていませんでした。
5回の深掘りで辿り着いた真因
なぜ同期場所を変えてもつらさが解消されないのか。「なぜ」を5回繰り返して構造を分析しました。
1. なぜ手動で同期するのか? → 同じ値の真実(State)が複数箇所に存在するから(二重管理)。 2. なぜ二重管理になるのか? → 状態をすべて中央(Context / Store)に集約しているから。 3. なぜ中央に全状態を集めるのか? → 一括で完了判定する関数を中央に置いていたから。 4. なぜ中央で一括実行する必要があるのか? → 各ドメインが「必要な入力が揃っているか」を自前で判定できないから。 5. なぜ自前で判定できないのか? → ドメインが自分の status を導き出すための入力・データ構造の定義(責務の割り当て)が、そもそも存在しなかったから。
因果の向きが真逆だった
真因は、判定の責務がどのドメインにも割り当てられておらず、「判定の持ち主」が中央にあり続けていたことでした。
各ドメインが自ら判断できないから、中央がすべてを集めて判定する。そして中央が大きくなるほど、あらゆる状態が吸い寄せられていく。
つまり、状態が中央にあるからつらいのではなく、判定が中央にあるからこそ状態が引き寄せられていた。
旧構造では、結果としてこの因果が逆向きに扱われていたのです。
画面の状態(表示制御・リセット・API 連動)は相互に影響し合うため、その連動を検知するには変化を中央に集めるしかありませんでした。
手動同期そのものが悪かったのではなく、判定が中央にある限り書かざるを得なかったのです。
flowchart TD
subgraph Center["【中央 Context / Store】"]
CenterData["全ドメインの入力・サーバーデータ・判定結果を集約"]
end
DomainA["[ エリア ]<br/>値が変わるたびに中央へ書き戻す"]
DomainB["[ 契約 ]<br/>値が変わるたびに中央へ書き戻す"]
DomainC["[ 使用量 ]<br/>値が変わるたびに中央へ書き戻す"]
DomainA -->|手動同期 PUSH| Center
DomainB -->|手動同期 PUSH| Center
DomainC -->|手動同期 PUSH| Center
「道具の層」と「構造の層」のズレ
振り返ると、4回目までの掘り下げでは「依存関係の複雑さ」に目が行き、Jotai などでグラフを整理する「道具の層」の発想に留まっていました。
しかし5回目まで掘り下げたことで、「問題の層(判定の所在という構造)」と「解の層(道具)」のズレに気づけました。
判定の所在を変えない限り、ライブラリを入れてもつらさは解消されません。むしろ依存関係が覆い隠されることで、本質的な課題が見えにくくなるリスクもありました。
これは Jotai の問題ではありません。むしろ、Jotai の派生 Atom によって「値を複製せず、必要なときに導出する」という発想に触れられたこと自体が、その奥にある「判定の所在」という構造課題への気づきにつながりました。派生 Atom には依存関係の追跡や購読粒度の最適化という固有の強みもあります。
ただ「導出する」という性質自体は Jotai 固有のものではなく、純粋関数でも実現できます。今回の規模ではその差が問題にならないと判断し、Jotai への移行は一度止めることにしました。導出という発想さえ手に入れば、現行の機能(React 標準の hook や SWR)で十分だったからです。
解決策:判定をドメインへ返す
やるべきことはシンプルで、判定の持ち主を中央から剥がし、本来のドメインへ返すことです。
同期(PUSH)から参照(PULL)への構造転換
旧構造はドメインが中央へ状態を渡す PUSH 型でした。新構造では関係性を逆転させます。
ドメイン側は画面進行を意識せず、自分が持つ入力とデータだけから自らの status を導き出します(以降、これを「自家導出」と呼びます)。
中央(オーケストレーター)がそれを外から読み取る(PULL)構造へ変更しました。
この変更によって、関心の向きが一方通行になり、手動の同期処理は不要になりました。
flowchart TD
subgraph UI["【オーケストレーター / UI】"]
Orchestrator["ドメインの status を参照し、画面の表示や進行のみを制御"]
end
subgraph Domains["【各ドメイン (自家導出)】"]
DomainA["[ エリア (model.ts) ]<br/>自分の状態から status を自家導出"]
DomainB["[ 契約 (model.ts) ]<br/>自分の状態から status を自家導出"]
DomainC["[ 使用量 (model.ts) ]<br/>自分の状態から status を自家導出"]
end
Orchestrator -->|参照 PULL| DomainA
Orchestrator -->|参照 PULL| DomainB
Orchestrator -->|参照 PULL| DomainC
データと判定の分離フロー
自家導出の前提として、画面上で扱うデータを 「入力・サーバーデータ・導出」 の3つに定義し直しました。導出値は保存せず、必要なときに入力とサーバーデータから計算します。これが二重管理を構造的に消す土台になります。
サーバーデータの取得には SWR を使い、判定は各ドメインの model.ts に置いた純粋関数で行います。純粋関数なので、React にも中央ストアにも依存しません。
// domains/contract/model.ts // 選択状態(判別可能なユニオン)からプランが選ばれているかを導出する純粋関数 export const isPlanSelected = (selection: ContractSelection): boolean => selection.type === "PlanSelected" || selection.type === "FullySelected";
ここで判定対象を「生のフォーム値が空でないか」ではなく「選択肢の集合に含まれているか」に変えた点が効きました。
たとえばエリアを変えると選べるプランが入れ替わりますが、フォームには前のエリアで選んだ planCode が残ります。
しかし現在の選択肢の集合を見れば、この古い値はもう有効な選択として扱われません。値の手動リセットを書かなくても、バグが構造的に起きなくなるのです。
こうして導出した結果は、読み取り専用の hook として公開します。
// domains/contract/useContractStatus.ts export const useContractStatus = () => { const { data: plans } = useSWR<Plans>("contract/plans", null); return { plansReady: plans !== undefined }; };
useSWR(key, fetcher) を素で呼ぶだけでは、マウント時やフォーカス時に再検証が走ってしまいます。第2引数に null を渡すこの形にして初めて、「参照しても通信が走らない」が成立します。
実際にデータを取得する側(画面本体のドメイン hook)は同じ key で fetcher 付きの useSWR を呼び、両者は SWR のグローバルキャッシュを介して同じデータを共有します。
この「副作用を持たない(passive)」性質が重要です。オーケストレーターは全ドメインの status を参照できますが、参照しただけで通信が走ることはありません。
データが必要かどうかを宣言するのは、あくまで UI 側のドメイン hook です。読み取りと要求を分けたことで、参照が副作用を呼ぶ連鎖を断てました。
オーケストレーターを God 化させない設計
オーケストレーターと呼んでいますが、実体は hook 1つです。処理を調停するわけではなく、全ドメインの status を参照できる唯一の拠点として、それを画面側のキーへ機械的に対応づけ、未移行のキーだけ旧 Context にフォールバックするだけの薄い層です。
// hooks/useOrchestratedGetStatus.ts const domainStatus = { "api/contract/plans": contract.plansReady, "fieldGroup/contract": contract.isComplete, "input/contract/planCode": contract.isPlanSelected, }; return (key: StatusKey) => key in domainStatus ? domainStatus[key] : legacyGetStatus(key); // 未移行のキーは旧 Context にフォールバック
状態のコピーを持たず、判定ロジックも書きません。
全ドメインの status を横断的に参照できる立場にあるからこそ、ドメイン横断ロジックも書けてしまいます。そのため、ここには計算・判定ロジックを持ち込まない制約を徹底しています。単一窓口を挟むのは呼び出し側の関心を分離するためであり、「振り分け」と「計算」は別物として扱います。
段階的に移行できる構造
このフォールバックには、移行戦略上の意味もあります。全ドメインを一度に書き換える必要はなく、移行が済んだドメインのキーだけが新しい判定に切り替わり、残りは旧 Context がそのまま処理します。
実際の作業も、エリア・契約・使用量といったドメインを1つずつ移していきました。
成果と残された課題
判定をドメインへ返し PUSH から PULL へ転換したことで、中央集権のつらさは解消されました。
手動同期は「減らすもの」ではなく「不要なもの」になりました。中央ストアへの同期は全廃し、ストア自体は未移行キーを判定する旧 Context の読み取り先として残るだけです。
また状態を「入力・サーバーデータ・導出」に再定義することで、同じ値を複数箇所に持たなくなり、二重管理も消えました。
冒頭の loading フラグ上書きバグが構造的に起きないのも同じ理由です。導出値は参照のたびに計算するので、どこかに保持されて後から上書きされる余地がありません。
新しい判定を追加するときも、中央ストアへの同期を書く必要はありません。
各ドメインの model.ts に純粋関数を足し、必要であればオーケストレーターに対応するキーを数行追加するだけで済みます。
3つの構造的トレードオフ
手動同期をなくした引き換えに、以下のトレードオフを引き受けています。
- 全体一望性の低下: ドメインの自律性を優先した結果、1箇所で全体を俯瞰できません。依存ルールもドメインへ分散できましたが、一望性を保つためにあえて中央に残す判断をしました。
- オーケストレーターの God 化リスク: 横断ロジックが書けてしまう構造のため、ロジックを持ち込まない規律(レビューや設計思想の共有)の維持が必要です。AI を用いて機械的な検知を行うことも検討の余地があります。
- エラーハンドリングと非同期対応: 正常系の整理を主眼としたため、
errorやisLoadingの統一処理、連打時のレースコンディション対策など異常系対応が今後の課題です。
まとめ
今回の経験を通して、2つの思考軸を得られました。
1. 痛みの発生源と解の層(レイヤー)を合わせる
問題はツールの可否ではなく「判定の所在」という構造にあります。レイヤーがズレたままコードに手を入れても、根本解決にはならないと気づけました。
まず構造を整え、必要に応じてツールを検討することが重要だと学びました。
2. 設計は足し算ではなく引き算で考える
最終的にやったことは、同期の仕組みを賢くすることではなく、同期が必要な理由そのものを消すことでした。
判定を持ち主に返すという引き算だけで、手動同期・自前ストア・二重管理がまとめて不要になります(オーケストレーターは値を保持しない読み取り専用の窓口で、旧ストアの代替ではありません)。
仕組みを足して問題に蓋をする前に、「そもそもこれは何のために必要なのか」を問い直す価値があります。
そして、この真因に辿り着けたのは、特別な技術ではなく以下2点によるものでした。
- ジョイン直後の視点: 先入観なくコードベース全体の違和感を客観視できた
- 「問い」の徹底: 表面的な症状ではなく「解くべき課題」を掘り下げた
長くコードに向き合うほど視野は狭くなりがちです(私自身も例外ではありません)。
だからこそ一度立ち止まり、基本に立ち返って問い続ける姿勢が、複雑な問題を解き明かす近道だと再認識しました。
ENECHANGE で働きませんか
ENECHANGE では、AI 時代の開発体験を支える技術選定から一緒に取り組んでくれる仲間を募集しています。ぜひ採用情報をご覧ください。