Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

細粒度リアクティブステートのスコープとライフサイクル

Avatar for ONDA, Takashi ONDA, Takashi
November 29, 2025
110

 細粒度リアクティブステートのスコープとライフサイクル

フロントエンド関西 2025 の発表資料です

Avatar for ONDA, Takashi

ONDA, Takashi

November 29, 2025
Tweet

Transcript

  1. 4 ステート管理前史 input type="hidden" jQuery AngularJS <?php $count = isset($_POST['count'])

    ? intval($_POST['count']) : 0; if (isset($_POST['op'])) { if ($_POST['op'] === 'inc') $count++; if ($_POST['op'] === 'dec') $count--; } ?> <html> <body> <p>Count: <?php echo $count; ?></p> <form method="post"> <input type="hidden" name="count" value="<?php echo $count; ?>" > <button type="submit" name="op" value="inc"> +1 </button> <button type="submit" name="op" value="dec"> -1 </button> </form> </body> </html> 宣言的 UI, コンポーネント時代の以前は?
  2. 5 ステート管理前史 input type="hidden" jQuery AngularJS <div id="count">0</div> <button id="inc">+1</button>

    <button id="dec">-1</button> <script> jQuery(function($) { var $count = $("#count"); $("#inc").on("click", function () { var current = parseInt($count.text(), 10); $count.text(current + 1); }); $("#dec").on("click", function () { var current = parseInt($count.text(), 10); $count.text(current - 1); }); }); </script> 宣言的 UI, コンポーネント時代の以前は?
  3. 6 ステート管理前史 input type="hidden" jQuery AngularJS <html ng-app="app"> <head> <script

    src="./angular.min.js"></script> </head> <body ng-controller="CounterCtrl"> <p>Count: {{ count }}</p> <button ng-click="inc()">+1</button> <button ng-click="dec()">-1</button> <script> angular.module('app', []) .controller('CounterCtrl', function($scope) { $scope.count = 0; $scope.inc = function() { $scope.count++; }; $scope.dec = function() { $scope.count--; }; }); </script> </body> </html> 宣言的 UI, コンポーネント時代の以前は?
  4. 7 React 登場 もたらしたもの コンポーネント Self-contained module Composability UI =

    f(state) 単方向データフロー みえてきた課題 state が外部から観測できない ロジック共有が難しい props バケツリレー class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } increment = () => { this.setState({ count: this.state.count + 1 }); }; decrement = () => { this.setState({ count: this.state.count - 1 }); }; render() { return ( <div> <p>Count: {this.state.count}</p> <button onClick={this.increment}>+1</button> <button onClick={this.decrement}>-1</button> </div> ); } } コンポーネント時代の幕開け ` `
  5. 8 Redux もたらしたもの UI = f(state) 単純な写像 ステートの可観測性 状態遷移は pure

    function でテスタブル props バケツリレーが解消 みえてきた課題 ボイラープレートが多い 単一の store に押し込めすぎた 肥大化 ライフサイクル管理が難しい function counter(state = { value: 0 }, action) { switch (action.type) { case 'INCREMENT': return { value: state.value + 1 }; case 'DECREMENT': return { value: state.value - 1 }; default: return state; } } class Counter extends React.Component { render() { const { value, onInc, onDec } = this.props; return ( <div> <p>Count: {value}</p> <button onClick={onInc}>+1</button> <button onClick={onDec}>-1</button> </div> ); } } Flux アーキテクチャの登場 ` `
  6. 9 MobX もたらしたもの ボイラープレート削減 状態を class で自然にモデリング derived state みえてきた課題

    class, decorator 依存 Redux の time travel のような可観測性はない class CounterState { @observable value = 0; @action inc() { this.value++; } @action dec() { this.value--; } @computed get double() { return this.value * 2; } } const state = new CounterState(); @observer class Counter extends React.Component { render() { return ( <div> <p>Count: {state.value}, {state.double}</p> <button onClick={() => state.inc()}>+1</button> <button onClick={() => state.dec()}>-1</button> </div> ); } } mutable state の復権
  7. 10 React Hooks もたらしたもの Functional Component でシンプルに (HoC, render props

    がほぼ不要) ロジックの分離・再利用 用途ごとに最適化した状態管理 SWR / React Query React Hook Form Zustand / Jotai / Valtio みえてきた課題 セマンティクスが複雑化 function Counter() { const { count, inc, dec } = useCounter(); return ( <div> <p>Count: {count}</p> <button onClick={inc}>+1</button> <button onClick={dec}>-1</button> </div> ); } function useCounter() { const [count, setCount] = useState(0); const inc = useCallback(() => { setCount(c => c + 1); }, []); const dec = useCallback(() => { setCount(c => c - 1); }, []); return { count, inc, dec }; } Functional Component の登場
  8. 11 Zustand もたらしたもの Vanilla JS での単体テスト 細粒度レンダリング import { createStore,

    useStore } from 'zustand'; const store = createStore((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })), dec: () => set(s => ({ count: s.count - 1 })), })); function Counter() { const count = useStore(store, (s) => s.count); const inc = useStore(store, (s) => s.inc); const dec = useStore(store, (s) => s.dec); return ( <div> <p>Count: {count}</p> <button onClick={inc}>+1</button> <button onClick={dec}>-1</button> </div> ); } Hooks 時代のシンプルな Flux
  9. 12 Valtio もたらしたもの Vanilla JS での単体テスト 細粒度レンダリング import { proxy,

    useSnapshot } from 'valtio'; const state = proxy({ count: 0 }) function Counter() { const snap = useSnapshot(state) return ( <div> <p>Count: {snap.count}</p> <button onClick={() => state.count++}>+1</button> <button onClick={() => state.count--}>-1</button> </div> ) } Hooks 時代の mutable state
  10. 13 Jotai もたらしたもの atom を派生、合成させてステートを構築 細粒度リアクテイビティ Vanilla JS (2.0 から)

    import { atom, useAtom, useAtomValue } from 'jotai'; const countAtom = atom(0); const doubleAtom = atom((get) => get(countAtom) * 2); function Counter() { const [count, setCount] = useAtom(countAtom); const double = useAtomValue(doubleAtom); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); return ( <div> <p>Count: {count}</p> <p>Double: {double}</p> <button onClick={increment}>+1</button> <button onClick={decrement}>-1</button> </div> ); } React における細粒度リアクティブステート
  11. 15 Solid.js import { onCleanup, createSignal } from "solid-js"; function

    CountingComponent() { const [count, setCount] = createSignal(0); const interval = setInterval( () => setCount(count => count + 1), 1000 ); onCleanup(() => clearInterval(interval)); return <div>Count value is {count()}</div>; }; Fine-grained reactivity
  12. 16 Solid.js import { onCleanup, createSignal } from "solid-js"; const

    [count, setCount] = createSignal(0); const interval = setInterval( () => setCount(count => count + 1), 1000 ); const doubled = () => count() * 2 const CountingComponent = () => { onCleanup(() => clearInterval(interval)); return <div>Doubled value is {doubled()}</div>; }; Fine-grained reactivity
  13. 17 Svelte 5 <script> let count = $state(0); let doubled

    = $derived(count * 2); </script> <button onclick={() => count++}> {doubled} </button> <p>{count} doubled is {doubled}</p> Runes - universal, fine-grained reactivity
  14. 18 TC39 Signals Angular, Bubble, Ember, FAST, MobX, Preact, Qwik,

    RxJS, Solid, Starbeam, Svelte, Vue, Wiz… Promise のように標準化 API を目指している TC39 Stage 1 (Proposal) const counter = new Signal.State(0); const doubled = new Signal.Computed( () => counter.get() * 2 ); // effect は Signals を実装する各ライブラリに任せられる effect(() => { element.innerText = doubled.get() }); setInterval(() => { counter.set(counter.get() + 1) }, 1000); Signals の標準化提案
  15. 21 Fine-grained Reactivity 要はスプレッドシート subtotal 列は計算で導出 変更されたセルに依存する箇所だけ再計算 React の初期の思想が厳しくなった フロントエンドが複雑化して

    コンポーネント/DOM ツリーが巨大に 全体を再レンダリングすると遅すぎる memoization / React Compiler ざっくりイメージをつかむ
  16. 23 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)); スコープ ナイーブに実装
  17. 24 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)); スコープ 単体テストの例 describe("Jotai Test", () => { test("total", () => { // Arrange const store = createStore(); 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 expect(store.get(appleLineSubtotal)).toBe(1000); expect(store.get(subtotal)).toBe(2300); expect(store.get(tax)).toBe(230); expect(store.get(total)).toBe(2530); }); });
  18. 25 スコープ 過剰に export してしまう 単体テストやコンポーネントで使うため 簡単に atom が参照できる エディタが補完してくれる

    新しい機能を作るときに依存しちゃう 生まれるのは密結合した atom グラフ 全体像が把握できない 再利用の阻害 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)); ナイーブに実装すると…
  19. 26 ライフサイクル Remix / React Router v7 起きた事象 1. 個数編集

    2. フォーム送信 3. 送信している間、個数が巻き戻る location が新しいオブジェクトになっていた deep equal では同じ値 function OrderPage() { const setAppleQty = useSetAtom(appleQty); const setOrangeQty = useSetAtom(orangeQty); const setBananaQty = useSetAtom(bananaQty); const location = useLocation(); useEffect(() => { const sp = new URLSearchParams(location.search); setAppleQty(parseInt(sp.get('apple'))); setOrangeQty(parseInt(sp.get('orange'))); setBananaQty(parseInt(sp.get('banana'))); }, [ setAppleQty, setOrangeQty, setBananaQty, location, ]); return ( <Form> <LineItemTable /> <Total /> <Submit /> </Form> ); } useEffect で初期化すると…
  20. 27 Bunshi Bunshi (formerly known as jotai-molecules) was born out

    of the need to create lots of state atoms for jotai, but it evolved as a general dependency injection tool for various state manager const TotalMolecule = molecule(() => { use(OrderPageScope) const lineItems = use(LineItemsMolecule); const subtotal = atom((get) => { return lineItems.reduce((sum, item) => { return sum + get(item.lineSubtotal); }, 0); }); const tax = atom((get) => get(subtotal) * 0.10); const total = atom((get) => get(subtotal) + get(tax)) return { total } }); function Total() { const atoms = useMolecule(TotalMolecule); const total = useAtomValue(atoms.total); return ( <div> <p>Total: {total}</p> </div> ); } library for creating states stores and other dependencies
  21. 28 スコープ クロージャでモジュール化 引数で依存を明示 抽象に依存 カプセル化 atom を props で渡す

    不変なので再レンダリング引き起こさない fine-grained reactivity 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> ); }); Bunshi の考え方だけを導入
  22. 29 ライフサイクル React Context に atom を格納 Storing an atom

    config in useState mount 時に atom を作成、初期化 コンポーネントのライフサイクルに同期 初期値と同期する値を峻別 あまりスマートではないが… function Root({ dependentAtoms, values, children }: PropsWithChildren<Props>) => { // mount 時に atom 作成、初期化 const atoms = useMemo( () => createAtoms(dependentAtoms, values), // biome-ignore lint/correctness/useExhaustiveDepen [dependentAtoms] ); // 初期値として必要な値と、同期する値を峻別 const setSync = useSetAtom(atoms.syncAtom); useEffect(() => { setSync(values.sync); }, [setSync, values.sync]) return ( <RootContext value={atoms}> {children} </RootContext> ); }); Bunshi の実装を参考に
  23. 30 今日お話ししたこと ステート管理の発展の歴史 細粒度リアクティブステート スコープとライフサイクル ​ ​ UI state =

    f(state) = graph(signals) ステート管理の歴史、 細粒度リアクティブステートの登場とその課題