13 人が閲覧(直近 30 日) React

Jotaiとは?atomで組むReact状態管理の使い方・テスト・v3移行【3.0.1対応】

Jotaiとは?atomで組むReact状態管理の使い方・テスト・v3移行【3.0.1対応】

Jotai(じょうたい)は、React の状態を「atom」という小さな単位に分けて持つ状態管理ライブラリです。名前は日本語の「状態」に由来し、Zustand と同じ Poimandres(pmndrs)のリポジトリで開発されています。2026年9月8日に v3.0.0、9月29日に v3.0.1 が公開され、React 18 以上・ESM のみの構成に変わりました。

掲載コードは jotai 3.0.1・React 19.3.1 の型定義で型検査(TypeScript 7.0.2)を通し、DevTools と Next.js 用の例を除いて Node.js v26.5.0 と jsdom で実行しました(fetch はモックに差し替え)。DevTools の画面表示と Next.js 上での SSR は実行していません。

まとめ:Jotaiの要点とv3.0.1時点の採用判断

  • Jotai は状態を atom 単位で持ち、その atom を読んでいるコンポーネントだけを再レンダーします。公式は useState と useContext の置き換えとして位置づけています。
  • 最新版は 3.0.1(2026年9月29日公開)で、React 18・TypeScript 5.5・Node.js 22.12 以上が必要です。CJS ビルドは無くなりました。
  • v3 では loadable が削除されて unwrap で代替し、atomFamily は jotai-family、Babel プラグインは jotai-babel という別パッケージへ移りました。
  • 3.0.0 には、子コンポーネントの useEffect で書いた値を親の useAtomValue が表示しない回帰があり、3.0.1 で修正されています。v3 に上げるなら ^3.0.1 を指定してください。
  • jotai-devtools は 0.15.0 から jotai 3 専用になりました。v2(2.20.0 以上)のまま使うなら 0.14.0 に留めます。
  • Next.js などの SSR と、テストでは、Provider でストアを分けます。Provider を省いたときのデフォルトストアは、リクエスト間やテスト間で値を持ち越します。
  • 状態を1つのオブジェクトにまとめて Redux DevTools で追いたいなら Zustand が向きます。開発が止まった Recoil の移行先としては、同じ atom モデルの Jotai が最も近い選択肢です。

Jotaiの仕組み:atomとストアの分離

atom(0) が返すのは値そのものではなく、「この状態の初期値は0」という設定(atom config)です。値はストアに保存され、公式ドキュメントの atom の項も「Atom configs don’t hold values」と明記しています。同じ atom を別々の Provider の下で使えば、値も別々に持てます。Provider を置かなければデフォルトストアが使われ、これを provider-less モードと呼びます。

状態の組み立て方はボトムアップです。小さな atom を作り、ほかの atom から値を計算する派生atomをつないで依存グラフを作ります。Redux や Zustand は1つのストアからセレクタで値を切り出すトップダウン型です。

atom の識別には、文字列キーではなくオブジェクトの参照を使います。そのためモジュールのトップレベルで atom() を呼ぶのが基本で、コンポーネントの中で呼ぶと描画のたびに別の atom ができます。公式ドキュメントは、描画中に作るなら useMemo か useRef で参照を固定するよう求めており、固定しないと useAtom と組み合わせたときに無限ループになりうると注意しています。

インストールと動作要件(npm・yarn・pnpm)

npm install jotai
# yarn add jotai
# pnpm add jotai

npm の jotai パッケージは、2026年10月28日〜11月3日の1週間に 9,253,805 回ダウンロードされています。v3 では動作要件が次のように引き上げられました。

項目 v2(2.20.3まで) v3(3.0.0以降)
React 17以上 18以上
TypeScript 3.8以上 5.5以上
Node.js 12.20以上 22.12以上
配布形式 CJS・ESM・UMD・SystemJS ESMのみ(ES2020構文)
読み込める入口 内部ファイルも指定可 公開エントリ7つのみ

v3 のパッケージが exports で公開しているのは、jotai・jotai/utils・jotai/vanilla・jotai/vanilla/utils・jotai/vanilla/internals・jotai/react・jotai/react/utils の7つだけです。内部ファイル jotai/esm/vanilla.mjs を直接 require すると ERR_PACKAGE_PATH_NOT_EXPORTED で失敗しました。CJS のコードから require('jotai/vanilla') する書き方は、Node.js v26.5.0 でそのまま動きます。

atom・useAtom・useAtomValue・useSetAtomの使い方

atom(初期値) で状態を定義し、コンポーネントからは3つのフックのどれかで使います。違いは、値を読むかどうかと、値が変わったときに再レンダーされるかどうかです。

フック 返り値 値の変化で再レンダー
useAtom [値, 更新関数] される
useAtomValue 値 される
useSetAtom 更新関数 されない
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'

const countAtom = atom(0)

function CountLabel() {
  const count = useAtomValue(countAtom) // 読むだけ
  return <p>count: {count}</p>
}

function IncrementButton() {
  const setCount = useSetAtom(countAtom) // 書くだけ(値が変わっても再レンダーしない)
  return <button onClick={() => setCount((c) => c + 1)}>+1</button>
}

function Editor() {
  const [count, setCount] = useAtom(countAtom) // 読み書き両方
  return <input type="number" value={count} onChange={(e) => setCount(Number(e.target.value))} />
}

export default function App() {
  return (
    <>
      <CountLabel />
      <IncrementButton />
      <Editor />
    </>
  )
}

このカウンターと同じ構成(読むだけ・書くだけ・読み書きの3コンポーネント)で、3回の +1 を1回の更新処理にまとめて実行し、各コンポーネントの描画回数を数えました(jotai 3.0.1・jsdom・StrictMode なし)。値を読む CountLabel と Editor はマウント時の1回に1回を足して2回、useSetAtom だけを使う IncrementButton はマウント時の1回のままでした。ボタンやフォームの送信処理のように書くだけのコンポーネントは、useAtom ではなく useSetAtom にすると不要な再レンダーを避けられます。

更新関数には、setCount(5) のような値も、setCount((c) => c + 1) のような関数も渡せます。プリミティブ atom の書き込みは React の setState と同じ振る舞いで、同じ値をセットした場合は購読者に通知されません(ストアの sub で購読して確認)。

派生atomと書き込み専用atom:Todoリストの実装例

atom() に関数を渡すと派生atomになります。第1引数が読み取り関数、第2引数が書き込み関数です。組み合わせは次の3通りです。

  • atom((get) => …):読み取り専用の派生atom。get で読んだ atom が変わると再計算されます。
  • atom((get) => …, (get, set, 引数) => …):読み書きできる派生atom。
  • atom(null, (get, set, 引数) => …):書き込み専用atom。更新処理だけを持ちます。
import { atom } from 'jotai'

type Todo = { id: number; title: string; done: boolean }
type Filter = 'all' | 'active' | 'done'

export const todosAtom = atom<Todo[]>([])
export const filterAtom = atom<Filter>('all')

// 読み取り専用の派生atom:todosAtom と filterAtom のどちらかが変わると再計算される
export const visibleTodosAtom = atom((get) => {
  const filter = get(filterAtom)
  const todos = get(todosAtom)
  if (filter === 'active') return todos.filter((t) => !t.done)
  if (filter === 'done') return todos.filter((t) => t.done)
  return todos
})

export const remainingCountAtom = atom((get) => get(todosAtom).filter((t) => !t.done).length)

// 書き込み専用atom(第1引数 null):更新ロジックをコンポーネントの外に置く
export const addTodoAtom = atom(null, (get, set, title: string) => {
  const todos = get(todosAtom)
  const id = todos.reduce((max, t) => Math.max(max, t.id), 0) + 1 // 既存の最大値+1で一意にする
  set(todosAtom, [...todos, { id, title, done: false }])
})

export const toggleTodoAtom = atom(null, (get, set, id: number) => {
  set(
    todosAtom,
    get(todosAtom).map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
  )
})

このコードで2件を追加し、1件目を完了にしてフィルタを active にすると、visibleTodosAtom は2件目だけ、remainingCountAtom は1を返しました。書き込み関数の中の get は依存関係として追跡されないため、書き込み専用atomが todosAtom の変化で再実行されることはありません。

更新ロジックを書き込み専用atomへ移すと、コンポーネントは useSetAtom(addTodoAtom) を呼ぶだけになり、ロジックは React を描画しなくてもストアだけで検証できます(後述の createStore とテストの章を参照)。逆に、ある atom を1つのコンポーネントでしか使わないなら、atom にせず useState のままにしてください。全部の状態を atom にすると、どこで何が読まれているかを追いにくくなります。

派生atom・selectAtom・splitAtomによる再レンダーの抑制

1つの atom に配列やオブジェクトをまとめて持つと、その一部しか使わないコンポーネントも、どこかが変わるたびに再レンダーされます。必要な部分だけを返す atom を作って購読範囲を狭めるのが Jotai の方法で、まず派生atomを使い、比較方法を指定したいときだけ selectAtom を使います。selectAtom のドキュメントは、この関数を「escape hatch(非常口)」と呼び、比較関数か前回値がどうしても要る場合に限るよう書いています。

import { atom, useAtomValue, useSetAtom, type PrimitiveAtom } from 'jotai'
import { selectAtom, splitAtom } from 'jotai/utils'

type Todo = { id: number; title: string; done: boolean }

export const todosAtom = atom<Todo[]>([
  { id: 1, title: '見積もりを出す', done: false },
  { id: 2, title: '請求書を送る', done: false },
])

// 派生atom:数値や文字列を返すなら、値が同じとき購読者は再レンダーしない
const remainingAtom = atom((get) => get(todosAtom).filter((t) => !t.done).length)

// 配列を返す派生atomは毎回新しい配列になるので、中身が同じでも再レンダーする
const activeIdsAtom = atom((get) => get(todosAtom).filter((t) => !t.done).map((t) => t.id))

// selectAtom:第3引数の比較関数で「中身が同じなら更新しない」と指定できる
const sameIds = (a: number[], b: number[]) => a.length === b.length && a.every((v, i) => v === b[i])
const activeIdsSelectedAtom = selectAtom(
  todosAtom,
  (todos) => todos.filter((t) => !t.done).map((t) => t.id),
  sameIds,
)

// splitAtom:配列を要素ごとの atom に分ける(第2引数は要素を識別するキー)
const todoAtomsAtom = splitAtom(todosAtom, (todo) => todo.id)

export function Remaining() {
  return <p>残り {useAtomValue(remainingAtom)} 件</p>
}

export function ActiveIds() {
  return <p>{useAtomValue(activeIdsAtom).join(',')}</p>
}

export function ActiveIdsSelected() {
  return <p>{useAtomValue(activeIdsSelectedAtom).join(',')}</p>
}

function TodoItem({ todoAtom }: { todoAtom: PrimitiveAtom<Todo> }) {
  const todo = useAtomValue(todoAtom)
  const setTodo = useSetAtom(todoAtom)
  return (
    <label>
      <input type="checkbox" checked={todo.done} onChange={() => setTodo({ ...todo, done: !todo.done })} />
      {todo.title}
    </label>
  )
}

export function TodoList() {
  const todoAtoms = useAtomValue(todoAtomsAtom)
  return todoAtoms.map((a) => <TodoItem key={`${a}`} todoAtom={a} />)
}

これらを並べ、2件目のタイトルを書き換えたときと1件目をクリックしたときの再レンダーを数えました(jotai 3.0.1・React 19.3.1・jsdom)。

コンポーネント(読むatom) 2件目の改名 1件目の完了
Remaining(件数の派生atom) 再レンダーなし 再レンダー
ActiveIds(配列の派生atom) 再レンダー 再レンダー
ActiveIdsSelected(selectAtom) 再レンダーなし 再レンダー
TodoList(splitAtom) 再レンダーなし 再レンダーなし
TodoItem 1件目 再レンダーなし 再レンダー
TodoItem 2件目 再レンダー 再レンダーなし

派生atomの値は Object.is で比べられるので、件数のような数値や文字列を返すなら、元の配列が変わっても値が同じ限り再レンダーは起きません。この例の activeIdsAtom は filter と map で計算のたびに新しい配列を作るため、未完了の ID が 1,2 のままでも改名だけで再レンダーされました。ここで selectAtom の第3引数に要素ごとに比べる関数を渡すと、中身が同じ間は更新されなくなります。

splitAtom は配列の要素ごとに atom を作ります。1件目をクリックしたとき再レンダーされたのはその TodoItem だけで、TodoList も2件目も描画されませんでした。TodoItem を memo で包まなくてもこうなります。第2引数の (todo) => todo.id は要素と atom を ID で結び付ける関数で、削除や並べ替えで位置がずれても対応が保たれます。要素の削除は、splitAtom が返す atom に { type: 'remove', atom } を書き込んで行えます。深いオブジェクトの一部を読み書きする jotai-optics の focusAtom もありますが、latest は2024年5月の 0.4.0 で止まっています。新規に組むなら、まず atom を小さく分けて持てないかを考えてください。

非同期atomとSuspense・unwrapの使い分け

読み取り関数が Promise を返すと非同期atomになります。useAtomValue で読むと、React 19 では React.use() で Promise を読み、解決するまでコンポーネントがサスペンドします(React 18 では従来の実装に切り替わります)。読み取り関数の第2引数で受け取る signal は AbortSignal で、依存する atom が変わって再計算が始まる前に、前の処理へ中断が通知されます。

import { Suspense } from 'react'
import { atom, useAtom, useAtomValue } from 'jotai'
import { unwrap } from 'jotai/utils'

type User = { id: number; name: string }

export const userIdAtom = atom(1)

// 依存する userIdAtom が変わると、再計算の前に前のリクエストへ中断が通知される
export const userAtom = atom(async (get, { signal }): Promise<User> => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${get(userIdAtom)}`, { signal })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
})

function UserName() {
  const user = useAtomValue(userAtom) // 解決するまでサスペンドする
  return <p>{user.name}</p>
}

// サスペンドさせたくない箇所は unwrap で「読み込み中は null」に変換する
const userOrNullAtom = unwrap(userAtom, () => null)

function UserBadge() {
  const user = useAtomValue(userOrNullAtom)
  return <span>{user ? user.name : '読み込み中…'}</span>
}

export default function Profile() {
  const [id, setId] = useAtom(userIdAtom)
  return (
    <>
      <button onClick={() => setId(id + 1)}>次のユーザー</button>
      <UserBadge />
      <Suspense fallback={<p>読み込み中…</p>}>
        <UserName />
      </Suspense>
    </>
  )
}

サスペンドさせたくない箇所には unwrap を使います。上の userOrNullAtom は、Promise が解決するまで第2引数の関数が返す値(ここでは null)を返し、解決後にユーザーを返します。v2 で読み込み状態の分岐に使われていた loadable は v3 で削除され、公式の移行ガイドは unwrap での置き換えを案内しています。ガイドには loadable と同じ { state: 'loading' | 'hasData' | 'hasError' } を返す実装例も載っています。

Promise が reject されたときは、Suspense で待つ書き方でも unwrap でもエラーがそのまま投げられます。unwrap は loadable と違ってエラーを処理しないので、上の例なら UserBadge と UserName の両方を覆う位置に ErrorBoundary を置いてください。v3 で追加された useAtomValueRaw(Promise を解決せずに返す)と useAtomValueRawSync は上級者向けで、通常は useAtomValue のままで足ります。

atomWithStorageによるlocalStorage永続化とgetOnInit

jotai/utils の atomWithStorage は、値を localStorage に JSON で保存する atom を作ります。既定の実装は storage イベントを購読するので、別タブでの変更も反映されます。

import { useAtom } from 'jotai'
import { atomWithStorage, RESET } from 'jotai/utils'

// localStorage の 'theme' キーに JSON で保存。別タブの変更も storage イベントで同期される
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light', undefined, {
  getOnInit: true, // 初回の読み取りから保存値を返す(既定 false では最初は 'light')
})

export function ThemeSwitch() {
  const [theme, setTheme] = useAtom(themeAtom)
  return (
    <>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>
      <button onClick={() => setTheme(RESET)}>初期値に戻す</button>
    </>
  )
}

注意したいのは getOnInit の既定値が false である点です。localStorage に “dark” を入れた状態で試すと、getOnInit なしの atom は最初の読み取りで初期値の “light” を返し、購読を始めたあとで “dark” に置き換わりました。getOnInit: true の atom は最初から “dark” を返します。SPA では、この差がテーマのちらつきとして見えます。

ただし SSR では、サーバーに localStorage がありません。getOnInit: true にすると、サーバーは初期値、クライアントは保存値で描画するため、結果が食い違ってハイドレーションエラーの原因になります。公式の Next.js ガイドは、保存値に依存する部分をクライアントだけで描画するよう勧めています。setTheme(RESET) を呼ぶと値が初期値に戻り、localStorage のキーも削除されました。sessionStorage や React Native の AsyncStorage を使う場合は、createJSONStorage(() => sessionStorage) のようにストレージを差し替えます。

onMountとjotai-effectによる副作用の書き方

atom を購読するコンポーネントが初めて現れたときに処理を走らせたい場合は、atom の onMount プロパティを使います。onMount は atom を更新する関数を受け取り、後片付けの関数を返せます。購読者がいなくなると、その後片付け関数が呼ばれます。

import { atom } from 'jotai'
import { atomEffect } from 'jotai-effect'
import { todosAtom } from './todo'

// onMount:atom を購読するコンポーネントが初めて現れたときに呼ばれ、戻り値が後片付けになる
export const widthAtom = atom(0)
widthAtom.onMount = (setWidth) => {
  const update = () => setWidth(window.innerWidth)
  update()
  window.addEventListener('resize', update)
  return () => window.removeEventListener('resize', update)
}

// atomEffect:依存する atom が変わるたびに副作用を実行する(jotai-effect)
export const saveTodosEffect = atomEffect((get) => {
  const todos = get(todosAtom)
  console.log('todos changed:', todos.length)
})
// 使う側で useAtomValue(saveTodosEffect) すると、そのコンポーネントのマウント中だけ有効になる

依存する atom が変わるたびに副作用を実行したい場合は、別パッケージの jotai-effect(2026年10月6日公開の 2.4.2)を npm install jotai-effect で追加します。peerDependencies は jotai >=2.20.0 || >=3.0.0-alpha.0 で、v2・v3 のどちらでも使えます。上のコードを jotai 3.0.1 で動かすと、saveTodosEffect はマウント時(0件)と Todo 追加時(1件)の2回実行され、resize イベントでは widthAtom の値が 1024 から 640 に更新されました。

v2 では、非同期atomの読み取り関数の中で setSelf を使って自分自身を更新できました。この機能は v3 で削除されています。移行ガイドに直接の代替は無く、用途に応じて onMount か jotai-effect で書き直すよう案内されています。

Providerとストアの使い分け

Next.jsのSSRでProviderが必要な理由

provider-less モードのデフォルトストアは、モジュールに1つだけ存在します。SSR ではこのストアがサーバーのプロセス内に残り、複数のリクエストで共有されます。公式の Next.js ガイドは、これがバグやセキュリティ上のリスクにつながると警告しています。ルートに Provider を置けば、ストアの寿命がリクエスト単位になります。公式ドキュメントは Next.js のほか、同じ作者の React フレームワーク Waku での SSR 設定も扱っています。

jotai の React 用エントリは、ファイル先頭に 'use client' が付いています。Provider とフックはクライアントコンポーネントでしか動かないので、サーバーコンポーネントの app/layout.tsx からは、次のようなクライアント側のラッパーを介して使います。

'use client'

import type { ReactNode } from 'react'
import { Provider } from 'jotai'
import { useHydrateAtoms } from 'jotai/utils'
import { filterAtom } from './todo'

function Hydrate({ filter, children }: { filter: 'all' | 'active' | 'done'; children: ReactNode }) {
  useHydrateAtoms([[filterAtom, filter]]) // ストアごとに1回だけ初期値を流し込む
  return children
}

// app/layout.tsx から <Providers filter="active">…</Providers> のように使う
export function Providers({ filter, children }: { filter: 'all' | 'active' | 'done'; children: ReactNode }) {
  return (
    <Provider>
      <Hydrate filter={filter}>{children}</Hydrate>
    </Provider>
  )
}

useHydrateAtoms は、サーバーで取得した値を atom の初期値として流し込むフックです。ストアごとに1回しか反映されないので、再レンダーで props が変わっても atom の値は変わりません。

React外からのatom操作:createStore

createStore() で作ったストアは、get・set・sub の3つのメソッドを持ちます。React の外、たとえば WebSocket の受信処理やテストコードから atom を読み書きするときに使います。jotai/vanilla の atom と createStore は React に依存せず、v3 の peerDependencies でも react は省略可能(optional)です。

import { createStore } from 'jotai'
import { todosAtom, addTodoAtom, remainingCountAtom } from './todo'

export const store = createStore() // <Provider store={store}> に渡せば React 側と共有できる

const unsub = store.sub(remainingCountAtom, () => {
  console.log('未完了:', store.get(remainingCountAtom))
})
store.set(addTodoAtom, '請求書を送る') // 未完了: 1
console.log(store.get(todosAtom).length) // 1
unsub()

実行すると「未完了: 1」と「1」が出力されます。同じストアを <Provider store={store}> に渡すと、React 側のコンポーネントと値を共有できます。Provider を使わない構成で同じことをするには、デフォルトストアを返す getDefaultStore() を使います。

Vitestによるテストとテスト間のストア分離

公式のテストガイドは、Testing Library の方針に沿って、Jotai を実装の詳細として扱い、利用者と同じ操作でコンポーネントを確かめるよう勧めています。そのうえで、テスト前に atom へ値を入れたいときは Provider を使う方法を示しています。次のテストは Vitest 5.1.0(environment: jsdom)と @testing-library/react 16.3.3 で、2本とも通りました。

import { atom, useAtom } from 'jotai'

export const countAtom = atom(0)

export function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>
}
import { afterEach, expect, test } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { Provider, createStore } from 'jotai'
import { Counter, countAtom } from './test-counter'

afterEach(cleanup)

test('ストアに初期値を入れてから描画する', () => {
  const store = createStore() // テストごとに新しいストアを作る
  store.set(countAtom, 5)
  render(
    <Provider store={store}>
      <Counter />
    </Provider>,
  )
  fireEvent.click(screen.getByRole('button'))
  expect(screen.getByRole('button').textContent).toBe('count: 6')
  expect(store.get(countAtom)).toBe(6) // React の外からも値を確かめられる
})

test('Provider で包めば前のテストの値は残らない', () => {
  render(
    <Provider>
      <Counter />
    </Provider>,
  )
  expect(screen.getByRole('button').textContent).toBe('count: 0')
})

Provider で包むのを省くと、テストはデフォルトストアを共有します。Provider なしの Counter を描画してボタンを1回押すテストと、同じく Provider なしで描画するだけのテストを同じファイルで続けて実行したところ、2本目は「count: 0」ではなく「count: 1」から始まりました。Vitest は既定(isolate: true)でテストファイルごとに環境を分けるため、値が残るのは同じファイル内のテストの間です。isolate: false にすると、同じワーカーで動く別ファイルにも持ち越されえます。テストごとに createStore() のストアを渡すか、空の <Provider> で包めば持ち越しは起きません。

初期値を流し込む方法は2つあります。上のように store.set で入れてから Provider に渡す方法と、公式ガイドのように useHydrateAtoms を呼ぶ小さなコンポーネントを Provider の内側に置く方法です。前者はテストの最後に store.get で値を直接確かめられるので、書き込み専用atomのロジックを検証するときに向いています。atom を単体で確かめたいときは、公式ガイドのとおり renderHook で useAtom を呼び、act の中で更新して result.current を検証します。

Jotai DevToolsの導入と使い方

atom の値と変更履歴を画面上で確認するには、別パッケージの jotai-devtools を使います。2026年10月5日公開の 0.15.0 で jotai v3 に対応し、peerDependencies が jotai >=3.0.0 に変わりました。最新の 0.15.1(10月6日)は v2 では入りません。v2 のまま使う場合、0.14.0 の peerDependencies は jotai >=2.20.0 なので、jotai を 2.20 系に上げたうえで npm install [email protected] と版を指定します。jotai 3.0.1 と jotai-devtools 0.15.1 を組み合わせた下のコードは型検査を通過しました。

import { Provider, createStore } from 'jotai'
import { DevTools } from 'jotai-devtools'
import 'jotai-devtools/styles.css'
import App from './counter'

const store = createStore()

export default function Root() {
  return (
    <Provider store={store}>
      <DevTools store={store} /> {/* 本番ビルドでは表示されない */}
      <App />
    </Provider>
  )
}

<DevTools /> は本番以外の環境でだけ動きますが、README は process.env.NODE_ENV の条件分岐で包み、CSS も条件分岐の中で読み込んで本番のバンドルから除く書き方を推奨しています。Next.js では next.config.ts の transpilePackages に 'jotai-devtools' を加えます。パネル上の atom 名は debugLabel から表示され、v3 で別パッケージになった jotai-babel の preset を入れると変数名が自動で付きます。

v2からv3への移行:削除されたAPIと置き換え先

v3.0.0 のリリースノートは「mostly backward-compatible」と説明し、移行ガイドにも、v2 で非推奨の警告が出ていないアプリはそのまま動くはずだと書かれています。削除・移動された機能は次の5つです。

v2の機能 v3での置き換え
jotai/utils の atomFamily jotai-family パッケージ
jotai/utils の loadable unwrap(または自作)
jotai/babel のプラグイン jotai-babel パッケージ
読み取り関数の setSelf onMount または jotai-effect
useAtomValue の delay store.sub を使う自作フック

jotai 3.0.1 の jotai/utils を実際に読み込むと、loadable と atomFamily のどちらもエクスポートに含まれていませんでした。atomFamily は import { atomFamily } from 'jotai-family' に書き換えれば、同じ引数で同じ atom を返す挙動はそのまま使えます(todoFamily(1) === todoFamily(1) が true になることを確認)。

3.0.0のuseAtomValueで子コンポーネントの書き込みが反映されない回帰

v2 の useAtomValue は、値が変わらなくてもマウント直後に必ず1回再レンダーしていました(カウンターと同じ条件で、マウント時の描画は v2.20.3 で2回、v3 で1回)。この1回には、初回描画から購読開始までの間に起きた更新を拾う役割がありました。React は子の useEffect を親より先に実行するので、子が useEffect で atom に書き込むと、親の購読はその後になります。

import { useEffect } from 'react'
import { atom, useAtomValue, useSetAtom } from 'jotai'

const statusAtom = atom('initial')

function Child() {
  const setStatus = useSetAtom(statusAtom)
  useEffect(() => {
    setStatus('written-by-child') // 子の useEffect は親より先に実行される
  }, [setStatus])
  return null
}

export function Parent() {
  const status = useAtomValue(statusAtom)
  return (
    <>
      <p>{status}</p>
      <Child />
    </>
  )
}
バージョン 親に表示された値
jotai 2.20.3 written-by-child
jotai 3.0.0 initial(取りこぼし)
jotai 3.0.1 written-by-child

3.0.1 のリリースノートは「This fixes a regression in useAtomValue introduced in v3.0.0. Please update.」と書いています。修正(PR #3373)は、購読直後に値を確認し直し、変わっていなければ再レンダーしないものです。3.0.0 でも useAtomValueRawSync に替えると “written-by-child” が表示されました。公式ドキュメントの useAtomValue の項には、この種の更新を取りこぼしうるという記述が2026年11月時点でも残っているため、取りこぼしが許されない画面では useAtomValueRawSync も候補です(並行レンダリングの恩恵は受けられません)。

Jotai v2からv3への移行手順

  1. まず v2 の最終版 2.20.3 に上げ、開発ビルドで非推奨の警告が出ないことを確認します。
  2. React 18・TypeScript 5.5・Node.js 22.12 以上を満たしているか確認します。CI の Node.js も含みます。
  3. 上の表の5機能を置き換えます。atomFamily と Babel プラグインは import 先の変更だけで済みます。
  4. jotai/esm/… などの内部パスを import していないか検索します。v3 では読み込めません。
  5. jotai-devtools を使っていれば 0.15 系に上げます。0.14.0 以前は v3 に対応していません。
  6. npm install jotai@^3.0.1 で更新し、子の useEffect で atom を書いている画面を重点的に確認します。

Zustand・Redux Toolkit・Recoilとの比較と選び方

ライブラリ 状態のモデル 最新版 週間DL数
Jotai atomの組み合わせ 3.0.1 9,253,805
Zustand 単一ストア 5.0.16 89,687,623
Redux Toolkit 単一ストア+slice 2.14.0 46,758,079
Recoil atom+selector 0.7.7 580,061

週間DL数は npm の2026年10月28日〜11月3日の値で、最新版は11月6日時点の npm の latest です。公式の比較ページは「Jotai is like Recoil. Zustand is like Redux.」と整理しています。ストアの置き場所も、Jotai は Provider(コンテキスト)を、Zustand はモジュールのストアを優先する設計です。

Jotai を選ぶのは、useState と useContext で回していた状態の受け渡しが辛くなってきた段階です。Provider を何重にも重ねていたり、値ごとに Context を分けていたりするなら、そのまま置き換えられます。状態を画面単位でコード分割したい場合や、非同期データを Suspense で待ちたい場合も、公式の比較ページは Jotai を勧めています。Zustand の使い方は「Zustandとは?読み方・使い方とv5の注意点【5.0.16対応】」で解説しています。

反対に、すべての状態変更をアクションとして記録し、Redux DevTools で時系列に巻き戻して調べる運用をしているなら、Jotai は向きません。サーバーから取得したデータのキャッシュや再取得が状態の大半を占めるなら、先に TanStack Query の導入を検討してください。Jotai に残るのは UI の状態だけになることが多いです。

Recoil の GitHub リポジトリはアーカイブ(読み取り専用)になっており、npm の最終公開は2023年3月の 0.7.7 です。Recoil から移るときは、atom({ key, default: 初期値 }) を atom(初期値) に書き換え、selector を派生atomに、atomFamily を jotai-family に置き換えます。Jotai は atom をオブジェクト参照で識別するので、キーの重複を気にする必要もなくなります。

よくある質問

Jotaiの読み方と名前の意味は何ですか?

「じょうたい」と読みます。日本語の「状態」が由来で、公式の比較ページにも「Jotai means “state” in Japanese.」とあります。同じ開発元の Zustand もドイツ語で「状態」を意味します。

Jotaiの作者は誰ですか?

作者は Daishi Kato 氏(GitHub: dai-shi)で、GitHub のプロフィールの所在地は Tokyo です。同じプロフィールで Zustand・Jotai・Valtio の3つの状態管理ライブラリと、React フレームワークの Waku をメンテナンスしていると紹介しています。GitHub リポジトリの初回コミットは2020年8月11日(UTC)で、npm には同日に 0.0.0 が登録され、8月30日に 0.1.0 が公開されました。

JotaiはReact Nativeでも使えますか?

使えます。公式の React Native ガイドは、atom を変更なしで使えると明記しています。永続化だけは localStorage が無いので、createJSONStorage(() => AsyncStorage) で作ったストレージを atomWithStorage に渡します。React Native でのアプリ開発全体を相談したい場合は、Flutter・React Native のアプリ開発支援も行っています。

Next.jsのServer ComponentsでJotaiのフックは使えますか?

使えません。jotai の React 用エントリには 'use client' が付いており、フックと Provider はクライアントコンポーネント専用です。サーバーで取得した値は props でクライアントコンポーネントに渡し、useHydrateAtoms で atom に流し込みます。

v3でatomFamilyが使えなくなったのはなぜですか?

削除されたわけではなく、別パッケージの jotai-family に移されました。npm install jotai-family で追加し、import 元を 'jotai/utils' から 'jotai-family' に変えれば、そのまま使えます。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.11 テックブログ Microsoft 2026年11月の月例更新:Outlookの悪用済みCVE-2026-124194と修正版の確認方法
  2. 2026.01.21 コラム 税務調査の録音は禁止?判例・裁決と国税局マニュアルで見る、断られたときの対応
  3. 2026.11.11 テックブログ JANISの不正アクセス:最大11,652名の医療機関情報と情報窃取プログラムの検知設定
  4. 2026.11.11 テックブログ SMBC日興証券の不正アクセス:持株会会員最大5.6万人の範囲と受託システムの分離設計
  5. 2025.10.24 テックブログ AWS障害の確認方法と直近の障害一覧|2026年の中東・us-east-1障害と原因・対策

RELATED POSTS 関連記事

目次