React状態管理の深掘り #4 Jotaiと原子(atom)モデル

読了 5分

#3のZustandは、状態を1つのストアに集めるトップダウンのツールでした。この記事で扱うJotaiは正反対です。状態を原子(atom)という小さな単位に分けたうえで、その原子を組み立ててより大きな状態を作る、ボトムアップのモデルです。同じクライアント状態の問題をまったく異なる角度から解く方式なので、2つを比べながら見ると状態管理の幅が広がります。

atomという発想 #

Jotaiの出発点は「状態を細かく分かれた原子単位で持とう」という考えです。1つの原子は、値1つを持つ最も小さな状態の断片です。

atoms.js
import { atom } from "jotai";

export const countAtom = atom(0);
export const nameAtom = atom("ゲスト");

atom(0)は「初期値が0の原子1つ」を定義します。注目すべきは、この原子自体には値が入っていないことです。原子は一種の設定(config)にすぎず、実際の値はコンポーネントがこの原子を購読する時点で決まります。おかげで、同じ原子を複数の場所で使っても互いに衝突しません。

useAtom — useStateのように使う #

原子をコンポーネントで使うときは、useAtomを呼び出します。使う形がuseStateとほぼ同じです。

Counter.jsx
import { useAtom } from "jotai";
import { countAtom } from "./atoms";

function Counter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <button onClick={() => setCount((c) => c - 1)}>-</button>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

[値, 設定関数]の組を返す形がuseStateとそっくりです。違いは、この状態がコンポーネントのローカルではなくグローバルに共有される点です。他のコンポーネントで同じcountAtomuseAtomすると同じ値を見て、片方で変えると両方が一緒に更新されます。#3のZustandと同じく、Jotaiも基本の使い方ではProviderでツリーを包む必要がありません。

読むだけ、または書くだけなら、より狭いフックを使います。

読み取り専用 / 書き込み専用
import { useAtomValue, useSetAtom } from "jotai";

const count = useAtomValue(countAtom);   // 値だけ読む
const setCount = useSetAtom(countAtom);  // 設定関数だけ(このコンポーネントは値の変化で再レンダリングされない)

useSetAtomは値を書くだけで購読はしないので、値が変わってもそのコンポーネントは再レンダリングされません。

派生原子 — 原子から原子を作る #

Jotaiの本当の力は、派生原子(derived atom)にあります。他の原子の値を読んで計算する新しい原子を定義できます。

派生原子
import { atom } from "jotai";

export const countAtom = atom(0);

// countAtomを読んで2倍を計算する読み取り専用の派生原子
export const doubledAtom = atom((get) => get(countAtom) * 2);

atom((get) => ...)のように関数を渡すと派生原子になります。get(countAtom)で他の原子の現在の値を読みます。countAtomが変わると、doubledAtomも自動的に再計算されます。 表計算ソフトで1つのセルを変えると、そのセルを参照していた数式が自動的に更新されるのと同じモデルです。

派生原子の使用
function DoubledView() {
  const doubled = useAtomValue(doubledAtom);
  return <p>2: {doubled}</p>;
}

複数の原子を組み合わせた派生原子も作れます。

複数の原子の組み合わせ
const priceAtom = atom(1000);
const quantityAtom = atom(2);
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom));

priceAtomquantityAtomのどちらか1つでも変わると、totalAtomが再計算されます。基礎講座 #14useMemoで手ずから計算していた派生値を、Jotaiでは原子のグラフが自分で管理します。

再レンダリングは原子単位で切れる #

Jotaiの再レンダリングモデルは単純です。コンポーネントは、自分がuseAtomした原子(とその原子が依存する原子)が変わるときだけ再レンダリングされます。

独立に再レンダリングされるコンポーネント
function NameInput() {
  const [name, setName] = useAtom(nameAtom);
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

function CountDisplay() {
  const count = useAtomValue(countAtom);
  return <p>{count}</p>; // nameAtomが変わってもここは再レンダリングされない
}

nameAtomに入力しても、countAtomだけを見るCountDisplayは描き直されません。Zustandでセレクターから得ていた精密さを、Jotaiでは状態を最初から原子に分けた構造そのものが提供します。

注記
ZustandとJotaiはどちらも軽量なクライアント状態ツールですが、アプローチが正反対です。Zustandは1つのストアからセレクターで必要な部分だけ取り出すトップダウンJotaiは小さな原子を定義して組み立てるボトムアップです。互いに依存する派生値が多い状態ならJotaiの原子グラフがすっきりし、ひとかたまりに束ねられたグローバル状態ならZustandの単一ストアが直感的です。

atomWithStorage — リロード後も保つ #

Zustandのpersistに対応するツールとして、JotaiにはatomWithStorageがあります。

localStorageに保存される原子
import { atomWithStorage } from "jotai/utils";

export const themeAtom = atomWithStorage("theme", "light");
使用
function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      現在: {theme}
    </button>
  );
}

第1引数がlocalStorageのキー、第2が初期値です。普通の原子のように使うと、値の変更が自動的に保存され、リロード後に復元されます。

まとめ #

Jotaiは、状態を小さな原子に分けて組み立てるボトムアップの状態管理ツールです。

  • atom(初期値) — 最も小さな状態の断片、Provider不要
  • useAtom / useAtomValue / useSetAtomuseStateに似た使い方、ただしグローバル共有
  • 派生原子 atom((get) => ...) — 他の原子を読んで計算、依存する原子が変わると自動更新
  • 再レンダリングが原子単位で切れて精密
  • atomWithStoragelocalStorageの保存と復元

ここまで軽量なクライアント状態ツール2つ(Zustand、Jotai)を見ました。次回「React状態管理の深掘り #5 Redux Toolkitとレガシーの文脈」では、一時代を支配し、今も多くのコードベースに残るReduxを、現在の推奨形であるRedux Toolkitで整理します。新しいプロジェクトでの位置づけも合わせて押さえます。

X