ReactでJotaiを使い、状態をatomに分けて共有する方法
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 にまとめました。
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つを試しました(検証用のファイルから、該当する行だけを抜き出しています)。
const setHungry = useSetAtom(isHungryAtom); // 読み取り専用の atom に書き込む
store.set(mealsAtom, '3'); // 数値の atom に文字列
store.set(feedAtom, 1); // 引数を受け取らない書き込み専用の atom に引数
出力は、この3つに当たる行を、型チェックの出力から抜き出しています。
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 になり、数値を入れると型エラーになりました。
const selectedIdAtom = atom(null); // null しか入らない
const typedIdAtom = atom<number | null>(null); // 数値も入る
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 を使います。
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回 |

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 で包むと、その中のコンポーネントは別のストアを使い、外とは値が分かれます。
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回ずつ押しました。

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 で次のように試しました。
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();
# 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 ができてしまいます。
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と増えました。
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 で区切ります。





