Slide 1

Slide 1 text

細粒度リアクティブステートの スコープとライフサイクル React と Jotai / Bunshi に見るスコープ・ライフサイクル管理の課題と解決策 株式会社一休 恩田 崇

Slide 2

Slide 2 text

2 自己紹介 京都在住 フルスタック、なんでも屋 一休レストランのフロントエンドアーキテクト 最近はバックエンドの Rust がお仕事の中心 フロントエンドは IE4/DHTML あたりから 株式会社一休 恩田 崇

Slide 3

Slide 3 text

3 今日お話しすること ステート管理の発展の歴史 細粒度リアクティブステート スコープとライフサイクル UI = f(state) ステート管理の歴史、 細粒度リアクティブステートの登場とその課題

Slide 4

Slide 4 text

4 ステート管理前史 input type="hidden" jQuery AngularJS

Count:

+1 -1 宣言的 UI, コンポーネント時代の以前は?

Slide 5

Slide 5 text

5 ステート管理前史 input type="hidden" jQuery AngularJS
0
+1 -1 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); }); }); 宣言的 UI, コンポーネント時代の以前は?

Slide 6

Slide 6 text

6 ステート管理前史 input type="hidden" jQuery AngularJS

Count: {{ count }}

+1 -1 angular.module('app', []) .controller('CounterCtrl', function($scope) { $scope.count = 0; $scope.inc = function() { $scope.count++; }; $scope.dec = function() { $scope.count--; }; }); 宣言的 UI, コンポーネント時代の以前は?

Slide 7

Slide 7 text

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 (

Count: {this.state.count}

+1 -1
); } } コンポーネント時代の幕開け ` `

Slide 8

Slide 8 text

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 (

Count: {value}

+1 -1
); } } Flux アーキテクチャの登場 ` `

Slide 9

Slide 9 text

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 (

Count: {state.value}, {state.double}

state.inc()}>+1 state.dec()}>-1
); } } mutable state の復権

Slide 10

Slide 10 text

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 (

Count: {count}

+1 -1
); } 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 の登場

Slide 11

Slide 11 text

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 (

Count: {count}

+1 -1
); } Hooks 時代のシンプルな Flux

Slide 12

Slide 12 text

12 Valtio もたらしたもの Vanilla JS での単体テスト 細粒度レンダリング import { proxy, useSnapshot } from 'valtio'; const state = proxy({ count: 0 }) function Counter() { const snap = useSnapshot(state) return (

Count: {snap.count}

state.count++}>+1 state.count--}>-1
) } Hooks 時代の mutable state

Slide 13

Slide 13 text

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 (

Count: {count}

Double: {double}

+1 -1
); } React における細粒度リアクティブステート

Slide 14

Slide 14 text

14 今日お話しすること ステート管理の発展の歴史 細粒度リアクティブステート スコープとライフサイクル UI = f(state) ステート管理の歴史、 細粒度リアクティブステートの登場とその課題

Slide 15

Slide 15 text

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
Count value is {count()}
; }; Fine-grained reactivity

Slide 16

Slide 16 text

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
Doubled value is {doubled()}
; }; Fine-grained reactivity

Slide 17

Slide 17 text

17 Svelte 5 let count = $state(0); let doubled = $derived(count * 2); count++}> {doubled}

{count} doubled is {doubled}

Runes - universal, fine-grained reactivity

Slide 18

Slide 18 text

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 の標準化提案

Slide 19

Slide 19 text

19 Fine-grained Reactivity 要はスプレッドシート ざっくりイメージをつかむ

Slide 20

Slide 20 text

20 Fine-grained Reactivity 要はスプレッドシート subtotal 列は計算で導出 ざっくりイメージをつかむ

Slide 21

Slide 21 text

21 Fine-grained Reactivity 要はスプレッドシート subtotal 列は計算で導出 変更されたセルに依存する箇所だけ再計算 React の初期の思想が厳しくなった フロントエンドが複雑化して コンポーネント/DOM ツリーが巨大に 全体を再レンダリングすると遅すぎる memoization / React Compiler ざっくりイメージをつかむ

Slide 22

Slide 22 text

22 今日お話しすること ステート管理の発展の歴史 細粒度リアクティブステート スコープとライフサイクル UI = f(state) ステート管理の歴史、 細粒度リアクティブステートの登場とその課題

Slide 23

Slide 23 text

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)); スコープ ナイーブに実装

Slide 24

Slide 24 text

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); }); });

Slide 25

Slide 25 text

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)); ナイーブに実装すると…

Slide 26

Slide 26 text

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 ( ); } useEffect で初期化すると…

Slide 27

Slide 27 text

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 (

Total: {total}

); } library for creating states stores and other dependencies

Slide 28

Slide 28 text

28 スコープ クロージャでモジュール化 引数で依存を明示 抽象に依存 カプセル化 atom を props で渡す 不変なので再レンダリング引き起こさない fine-grained reactivity function createTotalAtom(lineItems: Atom[]) { 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[] }; const Total = memo(({ lineItems }: Props) => { const totalAtom = useMemo( () => createTotalAtom(lineItems), [lineItems] ); const total = useAtomValue(totalAtom); return (

Total: {total}

); }); Bunshi の考え方だけを導入

Slide 29

Slide 29 text

29 ライフサイクル React Context に atom を格納 Storing an atom config in useState mount 時に atom を作成、初期化 コンポーネントのライフサイクルに同期 初期値と同期する値を峻別 あまりスマートではないが… function Root({ dependentAtoms, values, children }: PropsWithChildren) => { // 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 ( {children} ); }); Bunshi の実装を参考に

Slide 30

Slide 30 text

30 今日お話ししたこと ステート管理の発展の歴史 細粒度リアクティブステート スコープとライフサイクル ​ ​ UI state = f(state) = graph(signals) ステート管理の歴史、 細粒度リアクティブステートの登場とその課題

Slide 31

Slide 31 text

31 エンジニア募集中! 一休では、よりよいサービスを届ける仲間を募集しています。