Slide 1

Slide 1 text

更新系と状態 2025-04-23 Exploring State - LayerX Web Frontend Night

Slide 2

Slide 2 text

発表者紹介 uhyo 株式会社カオナビ フロントエンドエキスパート 好きな状態管理ライブラリはjotaiだが、 実は業務ではReduxを使っている 2

Slide 3

Slide 3 text

フロントエンドと状態設計 RSCなどでサーバーがUIに入り込んでくる中で、 フロントエンド(クライアントサイド)の 主な役割はインタラクションである。 そして、UI = f(state)という格言を踏まえると、 フロントエンドの肝に状態設計があることは 疑いようがない! 3

Slide 4

Slide 4 text

フロントエンドと更新系 このトークでは、更新系に焦点を当てます。 つまり、UIを通じてアプリ上のデータを更新する ときのフロントエンドの動きと、その裏にある 状態設計。 4

Slide 5

Slide 5 text

React 19と更新系 React 19では、useActionStateやuseOptimistic のように更新系のことを念頭に置いたAPIが 追加された。 便利ですね。 5

Slide 6

Slide 6 text

ご清聴ありがとうございました 6

Slide 7

Slide 7 text

というのは冗談で…… 状態を取り扱う組み込みのフックが増えてきた ことで、それらを活かした状態管理の重要性が 増してきた。 そこで、今回はステートを組み合わせて活用する 設計パターンをご紹介します。 7

Slide 8

Slide 8 text

今回のお題 データの一覧を見られる 編集ボタンがある 8

Slide 9

Slide 9 text

データを編集したときどうする? とりあえずClineさんに実装をお願いすると こうなった 1. 編集APIを呼び出す 2. データを再読み込みする 実装は分かりやすいけど無駄が多い…… 9

Slide 10

Slide 10 text

データを編集したときどうする? ぼく「全部再読み込みするのは無駄が多いので、 ローカルで当該データを上書きすればいいのでは (天才的発想)」 ※ 他の人が同時に編集していた場合の挙動とかはここでは 考えないことにします 10

Slide 11

Slide 11 text

データを編集したときどうする? { setItems((items) => items.map(/* … */)); updateItemApi(data) .then(/* … */); }} /> 11

Slide 12

Slide 12 text

データを編集したときどうする? ぼく「じゃあsetItemsを呼び出す必要があるから、 itemsの定義を見に行くか」 12

Slide 13

Slide 13 text

データを編集したときどうする? export const useData = ( page: number, ): { items: Item[]; totalPages: number } => { return use(fetchWithCache(page)); }; useさん「ステートを更新したい? うちそれやってないんで」 13

Slide 14

Slide 14 text

データを編集したときどうする? Suspense前提だと、フェッチしたデータが入って いるローカルステートを更新するのは不可。 useの裏にあるキャッシュを更新するのはアリ (useSWRとかをちゃんと使うときにやる方法) だが、ちゃんとやるのはだるい…… 14

Slide 15

Slide 15 text

アイデア: 差分をステートに持つ 「フェッチした時点のデータからローカルで 更新された差分」を別で持って、合成すると 良さそう。 15

Slide 16

Slide 16 text

アイデア: 差分をステートに持つ 16 fetchedItems ネットワーク localDiffs items ユーザーが目に するデータ 更新

Slide 17

Slide 17 text

実際の実装 const { items: fetchedItems, totalPages } = useData(currentPage); const [itemsLocalDiff, setItemsLocalDiff] = useState(new Map()); const items = useMemo(() => { return fetchedItems.map((item) => { const localDiff = itemsLocalDiff.get(item.id); if (localDiff) { return { ...item, description: localDiff.description }; } return item; }); }, [fetchedItems, itemsLocalDiff]); 17 2 つ の ス テ ー ト を useMemo 内 で 合 成 してitemsを得ている

Slide 18

Slide 18 text

比較してみよう 18 fetchedItems ネットワーク localDiffs items 更新 items ネットワーク 更新 ステートが分かれており、 それぞれの更新理由が明確 1つのステートが複数の 理由で更新される

Slide 19

Slide 19 text

ステート分割の考え方 ユーザーからは1つに見える状態でも、 裏のステートを複数に分けたほうがシンプルに 管理できる場合がある。 19 fetchedItems localDiffs items 「画面に表示されている一覧データ」を 「サーバーから取得したデータ」と 「ローカルで編集した差分」に分解した

Slide 20

Slide 20 text

応用編 20

Slide 21

Slide 21 text

考えてみよう では、ページネーションをユーザーが操作して、 別のページに行ったあと再度このページに戻って きたらどうする? 21 fetchedItems localDiffs items

Slide 22

Slide 22 text

考えてみよう では、ページネーションをユーザーが操作して、 別のページに行ったあと再度このページに戻って きたらどうする? ページネーションが操作されたら再度サーバー から読み込まれるのが自然なので、 ページ移動した時点でlocalDiffsを初期化したい。 22

Slide 23

Slide 23 text

普通の実装 const handlePageChange = (newPage: number) => { if (newPage > 0 && newPage <= totalPages) { startTransition(() => { setItemsLocalDiff(new Map()); setCurrentPage(newPage); }); } }; 23 ページ移動時にlocalDiffを空にする。 複数のステート更新はバッチ化される ので動きとしては問題ない

Slide 24

Slide 24 text

普通の実装 でも、設計としてはlocalDiffの初期化は ページ移動に付属して起きることなのに、 2つのステート更新を同時にやるのは何か微妙…… setItemsLocalDiff(new Map()); setCurrentPage(newPage); 24

Slide 25

Slide 25 text

従属的なステート設計をしたい localDiffは“今のページのデータ”に付随する 追加データであるということを表現したい。 そのことをステート設計で表現するには…… 25

Slide 26

Slide 26 text

アイデア: 従属先を覚えておく localDiffがどのitemsに従属するのか、keyで表現。 function useLocalDiff(key: object) { const [itemsLocalDiff, setItemsLocalDiff] = useState<{ key: object; localDiff: Map; }>({ key: {}, localDiff: new Map(), }); 26 従来のMapに加えて、どのオブジェクト にそのMapが紐づくのかを記憶

Slide 27

Slide 27 text

アイデア: 従属先を覚えておく const localDiff = itemsLocalDiff.key === key ? itemsLocalDiff.localDiff : new Map(); return [ localDiff, updateDiff /* 後述 */, ] as const; 27 現在のkeyがlocalDiffに紐づいたkeyと 異なる場合は、 新しいkeyに紐づいたdiffが存在しない ので、空のMapを返す

Slide 28

Slide 28 text

アイデア: 従属先を覚えておく function updateDiff(id: number, description: string) { setItemsLocalDiff((prev) => { const newLocalDiff = prev.key === key ? new Map(prev.localDiff) : new Map(); newLocalDiff.set(id, { description }); return { key, localDiff: newLocalDiff, }; }); } 28 ステート更新のタイミングで最新のkeyを反映

Slide 29

Slide 29 text

従属的なステート設計 この設計では、「従属先」を覚えておくことで、 従属先が変わったら自動的に初期化される ステートを自然な(エフェクト等に頼らない)形で 作ることができた。 覚えておくkeyが1つならメモリリークの心配も しなくていい。 29

Slide 30

Slide 30 text

サンプルコード ここまでの説明で紹介したサンプルコードは GitHubで閲覧可能です。 https://github.com/uhyo/sample-update-state 30

Slide 31

Slide 31 text

でもステートの取り回しが…… useMemoとか使うとなると、上流のコンポーネント で計算して下流のコンポーネントに渡すのが大変。 const { items: fetchedItems, totalPages } = useData(currentPage); const [itemsLocalDiff, updateItemsLocalDiff] = useLocalDiff(fetchedItems); const items = useMemo(/* 省略 */); 31

Slide 32

Slide 32 text

ステートの分割に適したライブラリ このトークで紹介したようなステートの分割・合成 に適したアーキテクチャを持ったステート管理 ライブラリを使えば、今回の設計を活かしつつ itemsをどこからでも参照できる。 32

Slide 33

Slide 33 text

ステートの分割に適したライブラリ たとえばJotaiとかどうですか? 33

Slide 34

Slide 34 text

更新系とJotai 2025-04-23 Exploring State - LayerX Web Frontend Night