この記事は一休.com Advent Calendar 2025の1日目の記事です。
一休.com レストランの開発を担当している恩田(@takashi_onda)です。
はじめに
昨日 2025/11/30 に開催されたフロントエンドカンファレンス関西で、「細粒度リアクティブステートのスコープとライフサイクル」というタイトルで発表を行いました。
発表ではまたしても終盤が駆け足になってしまいました。本稿では、その際に十分に触れられなかった論点のうち、特にスコープに焦点をあて、その課題と解決案をご紹介したいと思います。
細粒度リアクティブステート
細粒度リアクティブステート (fine-grained reactivity) 1 とは、Solid や Svelte 5 の Runes で謳われている、UI を必要最小限な単位でリアクティブに更新するフロントエンドのアーキテクチャです。現代の複雑化した Web アプリケーションで UX を軽快に保つためのステート管理の大きな潮流で、Signals として TC39 で標準化の議論が進められています。
その背景にあるのは、値の変更を依存グラフで追跡し、必要最小限の更新を可能にする宣言的な計算モデルです。大雑把なイメージをつかむなら「スプレッドシート」と考えれば分かりやすいでしょう。
|
|
上の例のように、スプレッドシートでは、セルの値が変更されるとそのセルに依存する他のセルが自動的に再計算されます。同様に、細粒度リアクティブステートでは値が変更されると、その値から計算で導出される派生値やそれらの値を利用する UI コンポーネントが自動的に更新されます。
このように小さな状態をボトムアップに組み立ててモデリングするのが、細粒度リアクティブステートの特徴です。
React においては、一休.com レストランも大いにお世話になっている Jotai が細粒度リアクティブステートを実現するライブラリにあたると言えます。
React はその計算モデルの特性上 fine-grained reactivity を実現するのが難しいフレームワークですが、コンポーネントを小さく分割し memoization すれば近しい挙動が可能です。React Compiler 2 による自動化で React でも実現しやすくなりました。
ナイーブな実装
ここからは React と Jotai を前提に話を進めます。さきほどのスプレッドシートの例を Jotai で実装してみましょう。
const appleUnitPrice = atom(100); const appleQty = atom(1); const orangeUnitPrice = atom(200); const orangeQty = atom(2); const bananaUnitPrice = atom(300); const bananaQty = atom(3); const appleLineSubtotal = atom((get) => { return get(appleUnitPrice) * get(appleQty); }); const orangeLineSubtotal = atom((get) => { return get(orangeUnitPrice) * get(orangeQty); }); const bananaLineSubtotal = atom((get) => { return get(bananaUnitPrice) * get(bananaQty); }); const subtotal = atom((get) => { return get(appleLineSubtotal) + get(orangeLineSubtotal) + get(bananaLineSubtotal); }); const tax = atom((get) => get(subtotal) * 0.10); const total = atom((get) => get(subtotal) + get(tax));
スプレッドシートがそのまま表現できていることが見て取れると思います。
次に単体テストを追加します。Jotai は Vanilla JS で利用できるので、Vitest などのテストフレームワークで簡単にテストが記述できます。
describe("Jotai Test", () => { test("total", () => { // arrange const store = createStore(); // assert: initial values expect(store.get(appleLineSubtotal)).toBe(100); expect(store.get(subtotal)).toBe(1400); expect(store.get(tax)).toBe(140); expect(store.get(total)).toBe(1540); // act store.set(appleQty, 10); // assert: changed values expect(store.get(appleLineSubtotal)).toBe(1000); expect(store.get(subtotal)).toBe(2300); expect(store.get(tax)).toBe(230); expect(store.get(total)).toBe(2530); }); });
コンポーネントからは次のように利用します。
function Total() { const total = useAtomValue(total); return <div>Total: {total}</div>; }
Jotai を使ってナイーブに実装すると、このようなコードになると思います。
私たちも当初は同様の実装でフロントエンドロジックを構築していましたが、規模の拡大とともにスコープとライフサイクルに起因する問題が顕在化してきました。
スコープ
ここで言うスコープは、その名の通りプログラミング言語における変数や関数の可視性のことで、具体的には atom がどこから参照できるかを指します。
ご存知の通り JavaScript のモジュール機構である ES Module はファイル単位でしか可視性の制御ができません。export してどこからでも参照できるようにするか、export せずそのファイル内に閉じるかの二択です。
実装しようとする機能(feature)が小さい間は、ひとつのファイルに atom を定義し Vitest の in-source test で単体テストを記述、最終的にコンポーネントだけを export すれば問題ありません。
しかし、一定以上の規模になると、すべてをひとつのファイルにまとめるのは現実的ではなくなります。atom 定義、単体テスト、カスタムフック、そしてコンポーネントとレイヤごとにファイルを分割3することになります。
ファイルを分割して単体テストやカスタムフックから atom を参照するために export したとき、問題となるのが VSCode や WebStorm などのエディタ・IDEの自動補完機能です。使いたいシンボル名を数文字入力すると補完候補が表示され、確定するだけで自動で import が挿入され、と、あまりに簡単に参照できてしまいます。
新しい機能を実装するとき、使えそうな atom が候補に出てきてタブで確定、とやっていくと、結果、できあがるのが密結合した巨大な atom グラフです。
const subtotal = atom((get) => { return get(appleLineSubtotal) + get(orangeLineSubtotal) + get(bananaLineSubtotal); }); const tax = atom((get) => get(subtotal) * 0.10); const total = atom((get) => get(subtotal) + get(tax));
さきほどの例で説明すると subtotal や tax, total は本来他の注文明細でも再利用可能なロジックです。ですが、この例では appleLineSubtotal をはじめとする atom に直接依存してしまっており使い回すことができません。また、直接的な依存は単体テストの arrange も煩雑にします。
実際のプロダクトの規模では、依存が広く深く複雑になり、構造を簡単に把握できなくなるのが一番の問題でした。数十個単位の atom がフラットにひとつの大きなグラフになってしまい、手を入れる際に、ホワイトボードで依存関係を再整理して理解の見直しが必要な場面もありました。わかりやすく例えるならば、巨大なひとつの関数で実装されたコードを保守するようなイメージです。
Bunshi
この問題の解決にヒントを与えてくれたのが Bunshi です。
もともとは Jotai で多数の atom を構造化する jotai-moleculous というライブラリとして開発されていました。ですが、その思想は React/Jotai に限らず汎用的に有効だったため、他の状態管理ライブラリやフレームワークでも利用できるように拡張されたのが Bunshi です。
詳細に踏み込むと長くなるので割愛4しますが、Bunshi が提供する以下の機能が問題解決のヒントになりました。
- atom をグルーピングした molecule という単位のモジュール
- moleculeInterface という抽象への依存とその解決の仕組み (Dependency Injection)
- 生存期間とライフサイクル (Scope5)
検討当初は Bunshi 自体の採用も視野に入れていましたが、コアドメイン部分の外部ライブラリへの依存を最小に保ちたかったこと、そしてデザインパターンとして同等の機能が実現できるという判断から、Bunshi の思想を参考にしつつ自前で実装することにしました。
解決
具体的なコードで見てみましょう。さきほどのスプレッドシートの例です。
function createTotalAtom(lineItems: Atom<number>[]) { const subtotal = atom((get) => { return lineItems.reduce((sum, item) => { return sum + get(item); }, 0); }); const tax = atom((get) => get(subtotal) * 0.10); const total = atom((get) => get(subtotal) + get(tax)); return total; } type Props = { lineItems: Atom<number>[] }; const Total = memo(({ lineItems }: Props) => { const totalAtom = useMemo( () => createTotalAtom(lineItems), [lineItems] ); const total = useAtomValue(totalAtom); return ( <div> <p>Total: {total}</p> </div> ); });
クロージャをモジュールの単位とします。createTotalAtom 関数がモジュールです。
この関数は引数で依存としての atom を受け取り、その依存を使った derived atom total を返します。依存を引数として明示的に記述するという制約がポイントで、これにより依存が無秩序に追加されてしまうことを抑止できます。
また、引数で依存が抽象化されることで、同じロジックを異なる依存で再利用できるようになります。lineItems は任意の注文明細で利用可能です。単体テストの arrange では、シンプルな primitive atom に差し替えられるのも大きな利点です。
中間の derived atom である subtotal や tax はクロージャ内に閉じているため、外部から参照できません。カプセル化の導入です。
コンポーネントでは props で atom を渡します。atom は不変なオブジェクトなので、メモ化された Total が再レンダリングされるのは total の依存グラフを構成する atom の値に変化があったときとなり、 fine-grained reactivity が実現できています。
おわりに
ここまで読んでいただきありがとうございました。
Jotai を題材に、細粒度リアクティブステートで複雑なフロントエンドの状態をモデリングするときに課題となるスコープとその解決案をご紹介しました。
人間の認知サイズに収まるように分割し、依存を明示化し、抽象を導入する。ここまでを振り返ると、細粒度リアクティブステートのような新しい概念を扱う場合でも、特別なことはなく、重要なのはプログラミングの普遍的な設計原則に立ち返ることでした。
本稿が、フロントエンドにおける状態管理設計を検討する際の一助となれば幸いです。
一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。
まずはカジュアル面談からお気軽にご応募ください!
- まだ定まった訳語がなく、リアクティビティとカタカナで表現するのにも違和感があったので、ここでは「細粒度リアクティブステート」と訳しています。fine-grained reactivity とそれを実現するステート管理の仕組み、ぐらいに捉えていただければと思います。↩
- 先日 1.0 がリリースされました。↩
- 依存の向きを一方向にするために、レイヤの単位で分割しています。↩
- bunshiを理解するという記事にわかりやすく解説されています。↩
- Bunshi の Scope はプログラミング言語におけるスコープではなく molecule の生存期間を意味します。React 実装では React Context が利用されています。↩

