Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for ONDA, Takashi ONDA, Takashi
November 29, 2025
2.7k

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

フロントエンド関西 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) ステート管理の歴史、 細粒度リアクティブステートの登場とその課題