ReactでJotaiを使い、状態をatomに分けて共有する方法

​​2026年10月24日​React​Jotai,TypeScript

Jotai では、状態を「名前」「ごはんの回数」のような値ごとの小さな atom に分けて作り、コンポーネントは使う atom だけを読みます。Provider で包まなくても、同じ atom を読むコンポーネントどうしで値が共有されます。ある atom が変わったとき描画し直されるのは、その atom(またはそこから計算した atom)を読んでいるコンポーネントだけです。

筆者は検証環境で、猫の名前とごはんの回数を atom に分け、型チェックの結果、Chromium での描画回数、Provider やストアを分けたときの値の持ち方を確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは jotai 3.0.1、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。型チェックは tsc、ブラウザでの確認は、esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作して行いました。StrictMode では包んでいません。非同期の atom や jotai/utils のユーティリティ、Next.js などのサーバー側の描画は試していません。

値ごとに atom を作る

atom は、atom 関数に初期値を渡して作ります。値を1つずつ、別の atom にします。検証では、次の atom を catAtoms.ts にまとめました。

ts
import { atom } from 'jotai';

// 小さな atom を、値ごとに1つずつ作る
export const nameAtom = atom('たま');
export const mealsAtom = atom(0);

// ほかの atom から計算する atom(読み取り専用)
export const isHungryAtom = atom((get) => get(mealsAtom) < 3);

// 書き込み専用の atom(アクション)
export const feedAtom = atom(null, (_get, set) => {
  set(mealsAtom, (meals) => meals + 1);
});

export const resetAtom = atom(null, (_get, set) => {
  set(nameAtom, 'たま');
  set(mealsAtom, 0);
});

isHungryAtom のように関数を渡すと、ほかの atom から計算する、読み取り専用の atom になります。関数の中で get した atom が変わると、計算し直されます。feedAtom のように1つ目の引数を null にして2つ目に関数を渡すと、書き込み専用の atom になります。複数の atom をまとめて変える処理を、ここに置けます。

atom 自体は値を持ちません。公式ドキュメントによると、atom は初期値や、値の読み方・書き方を書いた定義で、値はストアの中にあります。ドキュメントでは、この定義を atom config と呼んでいます。検証で mealsAtom を JSON にすると {"init":0} で、出てくるのは初期値の init だけでした。別のストアに10を入れた後でも変わりません(読み方・書き方を決める関数の read・write は JSON には出ません)。

TypeScript では初期値から型が付く

atom('たま') は文字列、atom(0) は数値の atom として推論されるので、型を書かずに済みます。Jotai の TypeScript ガイドによると、Jotai は TypeScript 5.5 以降と、strictNullChecks の有効化を前提にしています。

型が付いているので、間違った使い方は型チェックでエラーになります。検証では次の3つを試しました(検証用のファイルから、該当する行だけを抜き出しています)。

ts
const setHungry = useSetAtom(isHungryAtom); // 読み取り専用の atom に書き込む
store.set(mealsAtom, '3');                 // 数値の atom に文字列
store.set(feedAtom, 1);                    // 引数を受け取らない書き込み専用の atom に引数

出力は、この3つに当たる行を、型チェックの出力から抜き出しています。

text
errors/ReadOnly.tsx(5,32): error TS2769: No overload matches this call.
  The last overload gave the following error.
    Property 'write' is missing in type 'Atom<boolean>' but required in type 'WritableAtom<unknown, never[], unknown>'.
errors/WrongValue.ts(5,22): error TS2345: Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'.
errors/WrongValue.ts(6,21): error TS2554: Expected 1 arguments, but got 2.

読み取り専用の isHungryAtom には write がないので、useSetAtom に渡せません。

null で始めて、あとから数値を入れたい atom は、型を自分で書きます。atom(null) のままだと null しか入らない atom になり、数値を入れると型エラーになりました。

ts
const selectedIdAtom = atom(null);              // null しか入らない
const typedIdAtom = atom<number | null>(null);  // 数値も入る
text
errors/NullAtom.ts(7,27): error TS2345: Argument of type '1' is not assignable to parameter of type 'SetStateAction<null>'.

読むだけなら useAtomValue、書くだけなら useSetAtom

コンポーネントからは、useAtom などのフックで atom を使います。useAtom は useState と同じく、値と更新関数の組を返します。値だけが要るときは useAtomValue、書き込むだけのときは useSetAtom を使います。

tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { nameAtom, mealsAtom, isHungryAtom, feedAtom } from './catAtoms';

function MealCount() {
  const meals = useAtomValue(mealsAtom);
  return <p>ごはん:{meals}回</p>;
}

function NameTag() {
  const name = useAtomValue(nameAtom);
  return <p>名前:{name}</p>;
}

function HungryBadge() {
  const isHungry = useAtomValue(isHungryAtom);
  return <p>{isHungry ? 'おなかすいた' : 'まんぞく'}</p>;
}

function Buttons() {
  const feed = useSetAtom(feedAtom);
  const setName = useSetAtom(nameAtom);
  return (
    <div>
      <button onClick={() => feed()}>ごはんをあげる</button>
      <button onClick={() => setName('みけ')}>名前を「みけ」にする</button>
    </div>
  );
}

function ResetMeals() {
  const [, setMeals] = useAtom(mealsAtom);
  return <button onClick={() => setMeals(0)}>ごはんを0回に戻す</button>;
}

どのコンポーネントも、Provider で包まずに並べただけです。

描画し直されるのは、読んだ atom が変わったときだけ

検証では、それぞれのコンポーネントの中で描画された回数を数え、画面に出しました(上のコードでは、数える部分を省いています)。「ごはんをあげる」を4回、「名前を「みけ」にする」を1回押した結果です。

コンポーネント atom の使い方 最初 ごはん3回目の後 ごはん4回目の後 名前を変えた後
MealCount mealsAtom 1回 4回 5回 5回
NameTag nameAtom 1回 1回 1回 2回
HungryBadge isHungryAtom 1回 2回 2回 2回
Buttons 読まない(useSetAtom だけ) 1回 1回 1回 1回
ResetMeals useAtom(mealsAtom) の更新関数だけ 1回 4回 5回 5回
検証環境のChromiumで、「ごはん:4回(描画 5回)」「名前:みけ(描画 2回)」「まんぞく(描画 2回)」の3行、「ごはんをあげる」「名前を「みけ」にする」のボタンと「ボタンの描画 1回」、「ごはんを0回に戻す」のボタンと「useAtom のボタンの描画 5回」が表示された画面
ごはんを4回あげ、名前を「みけ」にした後の描画回数

MealCount はごはんのときだけ、NameTag は名前のときだけ描画し直されました。計算する atom を読んだ HungryBadge は、ごはんの回数が3になって isHungryAtom が true から false に変わった1回だけ描画し直されています。ごはんの回数が変わっても、計算した結果が同じなら描画し直されません。

useSetAtom だけを使う Buttons は、一度も描画し直されていません。一方、useAtom の更新関数だけを取り出した ResetMeals は、値を画面に出していないのに、ごはんのたびに描画し直されました。useAtom を使うと atom の値も読むことになるためです。公式ドキュメントにも、書き込むだけなら useSetAtom を使うと、余分な描画を避けられると書かれています。

名前が「みけ」のまま、同じボタンをもう一度押しても、NameTag は2回のままでした。インストールした jotai の中では、atom の新しい値を前の値と Object.is で比べていて、同じ値なら変更として扱っていません。

ポイント

値を表示するだけのコンポーネントは useAtomValue、ボタンのように書き込むだけのコンポーネントは useSetAtom にすると、描画し直される範囲が小さくなります。

Provider で、値を持つ場所を分ける

atom の値は「ストア」に入っています。Provider で包まないコンポーネントは、全体で1つの既定のストアを使います。Provider で包むと、その中のコンポーネントは別のストアを使い、外とは値が分かれます。

tsx
import { Provider, createStore, useAtom } from 'jotai';
import { mealsAtom } from './catAtoms';

function Counter({ label }: { label: string }) {
  const [meals, setMeals] = useAtom(mealsAtom);
  return (
    <p>
      {label}:{meals}回{' '}
      <button onClick={() => setMeals((m) => m + 1)}>+1</button>
    </p>
  );
}

const roomStore = createStore();
roomStore.set(mealsAtom, 5);

export function Scopes() {
  return (
    <>
      <Counter label="Providerなし A" />
      <Counter label="Providerなし B" />
      <Provider>
        <Counter label="Provider(storeなし)" />
      </Provider>
      <Provider store={roomStore}>
        <Counter label="Provider(roomStore)" />
      </Provider>
    </>
  );
}

4つとも同じ mealsAtom を使っています。「Providerなし A」の +1 を2回、Provider の中の2つを1回ずつ押しました。

検証環境のChromiumで、「Providerなし A:2回」「Providerなし B:2回」「Provider(storeなし):1回」「Provider(roomStore):6回」の4行が、それぞれ +1 のボタン付きで表示された画面
同じatomでも、Providerごとに値が分かれる

Provider の外の A と B は既定のストアを共有しているので、A を押しただけで B も2回になりました。store を渡さない Provider は自分のストアを持ち、0回から1回になっています。createStore で作り、先に5を入れておいた roomStore を渡した Provider は、5回から6回になりました。

公式ドキュメントでは、Provider を使う理由として、コンポーネントツリーの部分ごとに別の値を持たせること、atom の初期値を渡すこと、Provider をマウントし直してすべての atom を初期化することが挙げられています。

React の外からストアを読み書きする

createStore で作ったストアには get・set・sub があり、React のコンポーネントの外でも atom を読み書きできます。Node.js で次のように試しました。

ts
import { createStore } from 'jotai';
import { nameAtom, mealsAtom, isHungryAtom, feedAtom } from './catAtoms';

const store = createStore();
const unsubMeals = store.sub(mealsAtom, () => {
  console.log(`# sub(mealsAtom): ${store.get(mealsAtom)}`);
});
const unsubHungry = store.sub(isHungryAtom, () => {
  console.log(`# sub(isHungryAtom): ${store.get(isHungryAtom)}`);
});

for (let i = 0; i < 4; i++) store.set(feedAtom);
store.set(nameAtom, 'みけ');
unsubMeals();
unsubHungry();
text
# sub(mealsAtom): 1
# sub(mealsAtom): 2
# sub(mealsAtom): 3
# sub(isHungryAtom): false
# sub(mealsAtom): 4

書き込み専用の feedAtom も、store.set(feedAtom) で実行できました。isHungryAtom の購読は、値が false に変わったときに1回だけ呼ばれています。

別に作ったストアや既定のストア(getDefaultStore() で取れます)とは、値を共有しません。検証では、このストアを resetAtom で0に戻した後、もう1つ作ったストアの mealsAtom に10を入れました。このストアと既定のストアの mealsAtom は、0のままでした。

atom をコンポーネントの中で作るときは useMemo で包む

Jotai は atom を、同じオブジェクトかどうか(参照)で見分けます。コンポーネントの本体で atom(0) を呼ぶと、描画のたびに別の atom ができてしまいます。

tsx
export function AtomInRender() {
  const [count, setCount] = useAtom(atom(0));
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

検証環境でボタンを3回押すと、押すたびに描画し直されましたが、表示は0のままでした。描画し直すたびに新しい atom が作られ、新しい atom の初期値の0が表示されるためです。公式ドキュメントには、この書き方は無限ループになりうるとも書かれています。今回の検証環境では無限ループにはならず、コンソールにもエラーは出ませんでした。

useMemo で包んで、同じ atom を使い続けるようにすると、押すたびに1、2、3と増えました。

tsx
export function AtomWithMemo() {
  const countAtom = useMemo(() => atom(0), []);
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

ふつうは、atom はこの記事の catAtoms.ts のように、コンポーネントの外で作っておきます。

小さな atom に分けて、読む側を絞る

Jotai では、値ごとに atom を作り、計算が要るものは get で組み合わせた atom にします。コンポーネントは使う atom だけを読むので、検証でも、読んだ atom の値が変わったコンポーネントだけが描画し直されました。表示するだけなら useAtomValue、書き込むだけなら useSetAtom を選び、値を分けたい範囲は Provider で区切ります。

​Posted by 86