はい、承知いたしました。React開発者向けに、Jotaiによる状態管理の変化について詳細に説明する約5000語の記事を作成します。
React開発者必見!Jotaiで変わる状態管理
はじめに:Reactの状態管理、その進化と課題
React開発に携わる皆さんなら、アプリケーションの規模が大きくなるにつれて、状態管理が複雑になるという共通の課題に直面したことがあるでしょう。コンポーネントローカルな状態(useState)では追いつかなくなり、コンポーネントツリーの奥深くにある状態を共有するためにProps Drillingが発生したり、Context APIを使ってもパフォーマンスや管理の課題が出てきたりします。
多くの開発者は、これらの課題を解決するために、Reduxのような著名な状態管理ライブラリを導入してきました。Reduxは一元化されたストアと厳格なルールによって予測可能な状態管理を可能にしましたが、その設定にはボイラープレートが多く、学習コストも低いとは言えませんでした。その後、Context APIの進化やHooksの登場により、よりシンプルな状態管理の手法も登場しましたが、依然として大規模なアプリケーションにおける最適な状態管理手法は模索されています。
そんな中、近年注目を集めているのが「Jotai(ジョタイ)」です。Jotaiは、Recoilの設計思想にインスパイアされつつ、よりシンプルで柔軟、そしてTypeScriptとの相性が非常に良い状態管理ライブラリとして登場しました。その哲学は「プリミティブベース」。つまり、状態を小さな単位(Atom)として定義し、それらを組み合わせてより複雑な状態やロジックを構築していくというアプローチです。
本記事では、なぜ今JotaiがReactの状態管理を変革しうるのか、その核心に迫ります。Jotaiの基本的な概念から、実践的な使い方、高度なパターン、既存ライブラリとの比較、そして大規模アプリケーションでの活用法まで、詳細かつ網羅的に解説していきます。React開発者として、Jotaiを知ることは、これからの状態管理のあり方を考える上で間違いなく大きな一歩となるでしょう。
パート1:Reactにおける伝統的な状態管理手法と課題
Jotaiの素晴らしさを理解するためには、まずReactにおける従来の状態管理手法がどのようなものであったか、そしてそれぞれが抱える課題を明確にしておく必要があります。
1. コンポーネントローカルな状態 (useState, useReducer)
最も基本的で、そして最も一般的に使われるのが、コンポーネント自身の内部で状態を管理する手法です。
“`javascript
import React, { useState } from ‘react’;
function Counter() {
const [count, setCount] = useState(0);
return (
Count: {count}
setCount(count + 1)}>Increment
);
}
“`
利点:
非常にシンプルで理解しやすい。
コンポーネントのスコープ内に閉じているため、他の部分への影響がない。
React標準の機能であり、追加のライブラリは不要。
課題:
状態の共有: 異なるコンポーネント間で同じ状態を共有したり、親から子へ深く状態を渡したりする場合に問題が生じます。特に、離れたコンポーネント間で状態を共有するには、共通の祖先コンポーネントまで状態を引き上げ(State Lifting)、Propsとして子孫に渡していく必要があります(Props Drilling)。
Props Drilling: コンポーネントツリーを何階層も経由してPropsを渡していく必要があり、コードの見通しが悪化し、リファクタリングが困難になります。途中のコンポーネントはそのPropsを使わないにも関わらず、単に受け渡しの役割を果たすだけになり、関心の分離が損なわれます。
複雑な状態ロジック: useStateでは、関連する複数の状態を管理したり、状態遷移に複雑なロジックが必要な場合にコードが煩雑になりがちです。useReducerはこれを改善しますが、それでもグローバルな状態管理には向きません。
2. Context API
Props Drillingの問題を解決するために、Reactに標準で備わっているのがContext APIです。Context APIを使えば、コンポーネントツリーを下方向にデータを「注入」し、途中のコンポーネントを経由することなく、指定したContextを消費するコンポーネントでデータを受け取ることができます。
“`javascript
import React, { createContext, useContext, useState } from ‘react’;
const CountContext = createContext();
function CountProvider({ children }) {
const [count, setCount] = useState(0);
return (
{children}
);
}
function CounterDisplay() {
const { count } = useContext(CountContext);
return
Count: {count}
;
}
function CounterButtons() {
const { setCount } = useContext(CountContext);
return (
setCount(prev => prev + 1)}>Increment
setCount(prev => prev – 1)}>Decrement
);
}
// アプリケーションの一部
function App() {
return (
);
}
“`
利点:
Props Drillingを回避できる。
React標準の機能であり、追加ライブラリは不要。
比較的シンプルに、コンポーネントツリー内の広範囲で状態を共有できる。
課題:
パフォーマンス問題: Contextの値が更新されると、そのContextを消費している全ての コンポーネントが再レンダリングされる可能性があります。値がオブジェクトの場合、オブジェクトの参照が変わるたびにProvider配下のコンポーネントが多く再レンダリングされ、パフォーマンスが悪化しやすいです。useMemoやuseCallbackでProviderに渡す値をメモ化するなどの対策が必要ですが、それでも限界があります。
Context Hell: アプリケーションの規模が大きくなり、共有したい状態の種類が増えると、Contextが乱立し、Providerのネストが深くなる「Context Hell」状態になりがちです。
状態の分割と関連: Contextは通常、一つのProviderが一つの関連する状態のまとまりを管理します。状態の一部だけが必要な場合でも、Context全体を消費する必要があり、不要な依存や再レンダリングを引き起こす可能性があります。
3. Redux
長らくReactの状態管理のデファクトスタンダードであったのがReduxです。Fluxアーキテクチャに基づき、状態を一元化されたストアで管理し、ActionとReducerを通じてのみ状態を更新するという厳格なルールを定めています。
“`javascript
// actions.js
const increment = () => ({ type: ‘INCREMENT’ });
const decrement = () => ({ type: ‘DECREMENT’ });
// reducer.js
const initialState = { count: 0 };
const counterReducer = (state = initialState, action) => {
switch (action.type) {
case ‘INCREMENT’:
return { …state, count: state.count + 1 };
case ‘DECREMENT’:
return { …state, count: state.count – 1 };
default:
return state;
}
};
// store.js
import { createStore } from ‘redux’;
const store = createStore(counterReducer);
// component.js (with react-redux)
import React from ‘react’;
import { useSelector, useDispatch } from ‘react-redux’;
import { increment, decrement } from ‘./actions’;
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
Count: {count}
dispatch(increment())}>Increment
dispatch(decrement())}>Decrement
);
}
“`
利点:
予測可能性: 状態の更新がActionとReducerを通じてのみ行われるため、状態遷移が非常に予測しやすいです。デバッグが容易になります。
一元管理: アプリケーション全体の状態が一つのストアに集約されるため、全体像を把握しやすいです。
強力なエコシステム: Redux DevTools、Middleware (Thunk, Sagaなど) による非同期処理の管理、さまざまなUtilityライブラリなど、非常に成熟したエコシステムがあります。
課題:
ボイラープレート: Action Typeの定義、Action Creator、Reducer、Storeの設定など、状態管理の単位ごとに書くべきコードが多くなりがちです。特に、TypeScriptを使う場合はさらに型定義の記述が増えます。
学習コスト: Fluxアーキテクチャの概念(Store, Action, Dispatcher, Reducer)を理解する必要があります。非同期処理のためのMiddleware(Redux ThunkやRedux Saga)はさらに学習コストが高いです。
柔軟性の欠如: 厳格なルールが予測可能性をもたらす一方で、ちょっとした状態を扱うだけでもReduxの仕組みに乗せる必要があり、柔軟性に欠けると感じる場面があります。
コードの分離: Action、Reducer、Selectorなどが別々のファイルに分散しやすく、関連するロジックを追うのが難しい場合があります(ただし、Redux Toolkitの登場によりこの点は大きく改善されました)。
4. Recoil
Facebook(現Meta)によって開発されたRecoilは、これらの課題、特にContext APIのパフォーマンス問題とReduxのボイラープレート問題に対するReactフレンドリーな解決策として登場しました。AtomsとSelectorsという概念を導入し、ReactのSuspenseやConcurrent Modeとの親和性を高く設計されています。
“`javascript
import React from ‘react’;
import {
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from ‘recoil’;
// Atom: 状態の最小単位
const countState = atom({
key: ‘countState’, // グローバルにユニークなキー
default: 0,
});
// Selector: Atomや他のSelectorから派生した状態
const doubleCountState = selector({
key: ‘doubleCountState’,
get: ({ get }) => {
const count = get(countState);
return count * 2;
},
});
function Counter() {
const [count, setCount] = useRecoilState(countState); // 読み書き
const doubleCount = useRecoilValue(doubleCountState); // 読み取り専用
return (
Count: {count}
Double Count: {doubleCount}
setCount(prev => prev + 1)}>Increment
);
}
// Providerが必要 (RecoilRoot)
// ReactDOM.render( , …)
“`
利点:
AtomとSelectorによるシンプルでReact的なAPI。
Hooksとの親和性が高い。
状態のきめ細かい更新が可能で、Context APIよりもパフォーマンスに優れることが多い。
派生状態(Selector)の定義が直感的。
SuspenseやConcurrent Modeをサポート。
課題:
キーの管理: AtomやSelectorにはグローバルにユニークなキーが必要で、このキーの衝突を防ぐ管理が必要になります。
Providerの必須性: Context APIと同様に、状態を利用するコンポーネントツリーのルートにProvider(RecoilRoot)が必要です。
成熟度: Reduxのエコシステムに比べると歴史が浅く、ライブラリやツールが少ない場合があります。
ドキュメント/コミュニティ: Reduxほど日本語の情報が多くない場合があります。
パート2:Jotaiの登場 – プリミティブベースの状態管理
RecoilのAtom/Selectorモデルは多くの開発者に受け入れられましたが、そのキー管理の煩雑さや、特定のユースケースでのTypeScriptの扱いづらさなどが指摘されることもありました。ここで登場するのがJotaiです。
Jotaiは、Recoilの「状態を小さな単位(Atom)として扱う」という考え方をさらに推し進め、「Atomこそが状態のプリミティブである」という哲学のもとに設計されています。JotaiのAPIは驚くほどシンプルで、状態の定義から利用までを非常に直感的に行うことができます。
Jotaiの核心:atom
Jotaiの全てはatomから始まります。atomは、状態の最小単位であり、一つ一つの独立した状態を表現します。これはJavaScriptのプリミティブ値(文字列、数値、真偽値)やオブジェクト、配列など、どんな値でも保持できます。
atomは関数のように見えますが、これは状態の「定義」であり、それ自体が現在の値を保持しているわけではありません。Atomが保持する実際の値は、後述するProvider内で管理されます。
最も基本的なatomの定義は、初期値を渡すだけです。
“`javascript
import { atom } from ‘jotai’;
// 数値を保持するatom
const countAtom = atom(0);
// 文字列を保持するatom
const textAtom = atom(‘hello’);
// オブジェクトを保持するatom
const userAtom = atom({ name: ‘Alice’, age: 30 });
// 配列を保持するatom
const itemsAtom = atom([‘apple’, ‘banana’]);
“`
これらのatomは、Reactコンポーネントの外で定義されることが一般的です。これにより、状態の定義がコンポーネントのロジックから分離され、再利用しやすくなります。
Atomの読み書き:useAtomフック
定義したatomをコンポーネント内で利用するには、useAtomフックを使います。useAtomは、ReactのuseStateフックと同様に、現在のAtomの値と、その値を更新するためのセッター関数のペアを返します。
“`javascript
import React from ‘react’;
import { atom, useAtom } from ‘jotai’;
// カウンターのatom定義
const countAtom = atom(0);
function Counter() {
// countAtom の値を読み込み、更新関数を取得
const [count, setCount] = useAtom(countAtom);
return (
Count: {count}
{/ セッター関数を使って値を更新 /}
setCount(prev => prev + 1)}>Increment
setCount(prev => prev – 1)}>Decrement
);
}
“`
このコードを見てください。どこかでContextを定義したり、Reducerを書いたり、Actionを定義したりする必要はありません。atomを定義して、useAtomで使うだけです。驚くほどシンプルです。
useAtomはuseStateとよく似ていますが、決定的に異なるのは、useAtomが返す状態はグローバルに共有される(またはProviderスコープで共有される) という点です。複数のコンポーネントで同じcountAtomに対してuseAtomを使えば、それらはすべて同じcountの値を参照し、どれか一つがsetCountを呼び出せば、他のコンポーネントも新しい値で再レンダリングされます。
Atomの読み取り専用 (useAtomValue)
Atomの値は読みたいだけ、更新はしない、というコンポーネントもあります。そのような場合は、useAtomValueフックを使うことで、値だけを取得できます。これはパフォーマンス最適化に役立ちます。値が更新されても、その値を利用している コンポーネントだけが再レンダリングされます。
“`javascript
import React from ‘react’;
import { atom, useAtomValue } from ‘jotai’;
const messageAtom = atom(‘Hello, Jotai!’);
function MessageDisplay() {
// messageAtom の値を読み取り専用で取得
const message = useAtomValue(messageAtom);
return
{message}
;
}
“`
Atomの書き込み専用 (useSetAtom)
逆に、Atomの値を更新したいだけで、現在の値は必要ないという場合もあります。このような場合は、useSetAtomフックを使うことで、セッター関数だけを取得できます。これもまた、不要な値の変更による再レンダリングを防ぐパフォーマンス最適化になります。
“`javascript
import React from ‘react’;
import { atom, useSetAtom } from ‘jotai’;
const notificationAtom = atom(”);
function NotificationInput() {
// notificationAtom のセッター関数だけを取得
const setNotification = useSetAtom(notificationAtom);
const [inputValue, setInputValue] = React.useState(”);
const handleSubmit = () => {
setNotification(inputValue);
setInputValue(”);
};
return (
setInputValue(e.target.value)} />
Set Notification
);
}
“`
このように、useAtom、useAtomValue、useSetAtomを使い分けることで、コンポーネントが必要なものだけをAtomから取得し、不必要な再レンダリングを最小限に抑えることができます。
Providerについて
JotaiはデフォルトではReactのContext APIを利用して状態を管理します。そのため、状態を共有するためには、コンポーネントツリーのルートにProviderを配置するのが最も簡単な方法です。
“`javascript
import { Provider } from ‘jotai’;
import App from ‘./App’; // jotaiを使うコンポーネントが含まれる
ReactDOM.render(
{/ ここにProviderを配置 /}
,
document.getElementById(‘root’)
);
“`
ただし、Jotaiはデフォルトでグローバルなストアを持つため、アプリケーション全体で単一のストアで十分な場合は、明示的に<Provider>を配置しなくても動作します。しかし、これはテストの際にストアをモックしたり、複数の独立したストアが必要な場合(例: Micro Frontend)に不便になることがあります。明示的に<Provider>をルートに配置することが推奨されています。
Providerはネストすることも可能です。これにより、特定のサブツリー内だけで有効なAtomを定義したり、テスト時に特定のProviderでAtomの値をオーバーライドしたりといった高度な使い方ができます。
なぜJotaiは状態管理を変えるのか? – プリミティブとボトムアップ
従来の多くの状態管理ライブラリは、アプリケーション全体の状態を一つの大きなオブジェクト(ストア) として管理し、そのストアに対する更新をReducerのような仕組みで制御するという、トップダウンまたは集中型 のアプローチをとっていました。
Jotaiのアプローチは全く異なります。Atomはそれぞれが独立した小さな状態の「プリミティブ」です。アプリケーションの状態は、これらの無数の小さなAtomの集まり として考えられます。必要なときに必要なAtomを定義し、それらをコンポーネントから直接利用する。これはまさにボトムアップ のアプローチです。
このプリミティブベース・ボトムアップのアプローチが、Jotaiを特徴づけ、いくつかの重要な利点をもたらします。
驚くほどのシンプルさ: 複雑な概念や儀式(Reducer、Action、Middlewareなど)を学ぶ必要がありません。状態はAtomとして定義し、フックで利用する、ただそれだけです。学習コストが非常に低いです。
最小限のAPI: atom、useAtom、useAtomValue、useSetAtomあたりを覚えれば基本的なことはほとんどできてしまいます。APIが少ないため、迷うことがありません。
高い柔軟性: グローバルな状態、コンポーネントツリーの一部で共有される状態、派生状態、非同期状態など、様々な種類の状態を同じatomという概念で扱うことができます。
優れた開発者体験: ボイラープレートが少なく、関連するコード(状態の定義と利用)がコンポーネントの近くに配置されることが多いため、コードの見通しが良くなります。特にTypeScriptとの相性が抜群で、型推論が強力に働きます。
次のパートでは、この「プリミティブベース」という考え方が、どのようにしてより複雑な状態管理のパターンに対応していくのかを見ていきます。
パート3:Jotaiの応用 – 派生アトム、非同期処理、アトムファミリー
基本的なAtomの読み書きだけでなく、Jotaiはより複雑な状態やロジックを扱うための強力なパターンを提供しています。これらもまた、Atomを「プリミティブ」として組み合わせるという哲学に基づいています。
派生アトム (Derived Atoms)
あるAtomの値に基づいて計算される状態や、他のAtomの値に依存する状態は、新しいAtomとして定義できます。これを「派生アトム」と呼びます。派生アトムは、値を計算するための関数を渡して定義します。この関数は、他のAtomの値を取得するためのget関数を引数として受け取ります。
“`javascript
import { atom } from ‘jotai’;
const countAtom = atom(0); // 基本となる数値アトム
// countAtom の値の2倍を計算する派生アトム(読み取り専用)
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// countAtom の値が偶数かどうかを判定する派生アトム(読み取り専用)
const isEvenAtom = atom((get) => get(countAtom) % 2 === 0);
// countAtom の値に基づいてメッセージを生成する派生アトム(読み取り専用)
const messageAtom = atom((get) => {
const count = get(countAtom);
return count > 10 ? ‘Count is large!’ : ‘Count is small.’;
});
“`
派生アトムは、自身が依存しているAtom(上記の例ではcountAtom)の値が更新されると自動的に再計算されます。そして、その派生アトムの値を利用しているコンポーネントだけが再レンダリングされます。これはReduxのセレクターに似ていますが、よりシンプルに、Atomを組み合わせて定義できます。
読み書き可能な派生アトム
派生アトムは読み取り専用だけでなく、読み書き可能にすることもできます。これは、Atomの定義に関数ペア(getterとsetter)を渡すことで実現します。
“`javascript
import { atom } from ‘jotai’;
const firstNameAtom = atom(‘John’);
const lastNameAtom = atom(‘Doe’);
// 読み書き可能な派生アトム:フルネーム
const fullNameAtom = atom(
// Getter: firstNameAtomとlastNameAtomを組み合わせてフルネームを生成
(get) => ${get(firstNameAtom)} ${get(lastNameAtom)},
// Setter: フルネームを受け取り、firstNameAtomとlastNameAtomを更新
(get, set, fullName) => {
const [firstName, …lastNameParts] = fullName.split(‘ ‘);
const lastName = lastNameParts.join(‘ ‘);
set(firstNameAtom, firstName);
set(lastNameAtom, lastName);
}
);
function NameEditor() {
const [fullName, setFullName] = useAtom(fullNameAtom); // fullNameAtom を利用
return (
setFullName(e.target.value)} // fullNameAtom を更新
/>
First Name: {useAtomValue(firstNameAtom)}
{/ 参照 /}
Last Name: {useAtomValue(lastNameAtom)}
{/ 参照 /}
);
}
“`
この例では、fullNameAtomという派生アトムを定義しています。このAtomを更新(setFullNameを呼び出し)すると、その内部のsetter関数が実行され、依存しているfirstNameAtomとlastNameAtomが更新されます。これにより、UI上はフルネームを編集しているように見えつつ、内部の状態は名と姓に分割して管理することができます。これは複雑なフォームの状態管理などで非常に強力なパターンです。
非同期アトム (Async Atoms)
Jotaiでは、非同期処理の結果を保持するAtomを簡単に定義できます。Atomの初期値またはgetter関数としてPromiseを返す関数を渡すだけです。
“`javascript
import { atom, useAtom } from ‘jotai’;
import { Suspense } from ‘react’;
// ユーザーデータをフェッチする非同期アトム
const userAtom = atom(async () => {
console.log(‘Fetching user…’);
const response = await fetch(‘https://jsonplaceholder.typicode.com/users/1’);
const data = await response.json();
console.log(‘User fetched:’, data);
return data; // Promiseを解決した値がAtomの値となる
});
function UserDisplay() {
// useAtom は非同期アトムの場合、Promiseを解決するまでSuspenseがfallbackを表示する
const [user] = useAtom(userAtom);
return (
User Info
Name: {user.name}
Email: {user.email}
);
}
function App() {
return (
Loading user…\
}> {/ Suspenseで囲む /}
);
}
“`
非同期アトムを使う場合、そのAtomを読み込むコンポーネントは、ReactのSuspenseコンポーネントで囲む必要があります。Suspenseは、非同期処理が完了するまでの間、fallbackプロパティで指定した内容を表示します。
非同期アトムの読み込み・エラー状態のハンドリング:
useAtomフックは、非同期アトムに対して使うと、値だけでなく、そのPromiseの状態(保留中、解決済み、拒否済み)も提供します。これにより、Suspenseを使わずに、手動でローディング状態やエラー状態を扱うことも可能です。
“`javascript
import React from ‘react’;
import { atom, useAtom } from ‘jotai’;
const asyncDataAtom = atom(async () => {
// サンプル用の遅延
await new Promise(resolve => setTimeout(resolve, 1000));
if (Math.random() > 0.7) throw new Error(‘Failed to load!’);
return ‘Async data loaded!’;
});
function AsyncDataDisplay() {
const [data, setData, { loading, error }] = useAtom(asyncDataAtom);
if (loading) return
Loading…
;
if (error) return
Error: {error.message}
;
return (
{data}
setData()}>Reload {/ セッターに関数を渡すと再実行 /}
);
}
“`
useAtomが返す配列の第3要素は、非同期の状態に関する情報を持つオブジェクトです。loadingがtrueの間はPromiseが保留中、errorにErrorオブジェクトが入っている場合は拒否済みです。解決済みの値は第1要素のdataに入ります。また、非同期アトムのセッター関数を引数なしで呼び出すと、getter関数が再実行され、データを再フェッチできます。
アトムファミリー (Atom Families)
リスト表示されるアイテムそれぞれが固有の状態を持つ場合(例: ToDoリストの個々のToDoの完了状態、商品の数量など)、そのアイテムのIDごとにAtomを動的に生成したいことがあります。このような場合に「アトムファミリー」が役立ちます。JotaiではatomFamilyユーティリティを使って定義します。
atomFamilyはファクトリ関数を返します。このファクトリ関数にキー(通常はアイテムのID)を渡すと、そのキーに対応するAtomが生成されます。
“`javascript
import { atom, useAtom } from ‘jotai’;
import { atomFamily } from ‘jotai/utils’; // jotai/utils からインポート
type Todo = {
id: number;
text: string;
isCompleted: boolean;
};
// Todoアイテムのリスト全体を保持するアトム
const todosAtom = atom([
{ id: 1, text: ‘Learn Jotai’, isCompleted: false },
{ id: 2, text: ‘Build App’, isCompleted: false },
]);
// TodoのIDをキーとするアトムファミリー
// 各Todoの完了状態だけを管理する
const todoCompletedAtomFamily = atomFamily((todoId: number) =>
atom(false) // 各todoIdに対して boolean型の atom(false) を生成
);
function TodoItem({ todo }: { todo: Todo }) {
// todoCompletedAtomFamily に todo.id を渡して、このアイテム専用の completed Atomを取得
const [isCompleted, setIsCompleted] = useAtom(todoCompletedAtomFamily(todo.id));
return (
setIsCompleted(prev => !prev)}
/>
{todo.text} (ID: {todo.id})
);
}
function TodoList() {
const [todos] = useAtom(todosAtom);
return (
);
}
“`
この例では、todoCompletedAtomFamilyが、個々のToDoアイテムのIDをキーとして、そのアイテムの完了状態を管理するAtomを動的に生成します。各TodoItemコンポーネントは、自身のtodo.idを使って対応するAtomを取得し、その状態を独立して操作できます。これにより、リスト内の他のアイテムの状態に影響を与えることなく、個別のアイテムの状態を管理できます。
atomFamilyで生成されたAtomは、それが参照されなくなると自動的にメモリから解放されるよう設計されています(ただし、詳細な挙動や設定はJotaiのバージョンや設定に依存する場合があります)。
その他のユーティリティアトム
Jotaiには、特定ユースケースに便利なユーティリティアトムがいくつか用意されています (jotai/utils からインポート)。
atomWithStorage : localStorageやsessionStorageなど、ブラウザのストレージと同期するAtomを簡単に作成できます。
atomWithReducer : ReduxのReducerのようなパターンで状態を管理したい場合に便利です。
atomWithReset : 値を初期状態にリセットできるAtomを作成します。
splitAtom : 配列のアトムを、その要素ごとのアトムの配列に変換します。リスト表示などで要素ごとに状態を操作したい場合に便利です。
これらのユーティリティを利用することで、より複雑な状態管理パターンや、外部との連携をシンプルに記述できます。
パート4:JotaiとReactエコシステムとの連携
JotaiはReactのために設計されており、Reactの機能や他のライブラリとの連携もスムーズに行えます。
React.memo と useCallback
Atomの値が更新されると、そのAtomまたは派生Atomを利用しているコンポーネントが再レンダリングされます。これはJotaiの設計上自然な挙動ですが、不要な再レンダリングは避けるべきです。Reactのパフォーマンス最適化手法であるReact.memoやuseCallbackは、Jotaiと組み合わせることで効果を発揮します。
特に、Atomの値がオブジェクトや配列などの参照型である場合、値が更新されなくてもオブジェクトの参照が変わるだけでuseAtomが新しい値を返したとみなされ、コンポーネントが再レンダリングされることがあります。このような場合、子コンポーネントをReact.memoでメモ化しておき、Propsとして渡すコールバック関数をuseCallbackでメモ化することで、親コンポーネントのAtom更新による子コンポーネントの不要な再レンダリングを防ぐことができます。
また、JotaiのuseAtomValueや派生アトムは、値の変更を非常に効率的に検知するため、Context APIで発生しがちな「値全体が変わったから全部再レンダリング」という問題を避け、必要な部分だけを再レンダリングします。これはJotaiの設計上の大きな利点の一つです。
TypeScript サポート
JotaiはTypeScriptで書かれており、TypeScriptとの親和性が非常に高いです。Atomを定義する際に型を指定することで、値の読み書き時に強力な型補完と型チェックの恩恵を受けられます。
“`typescript jsx
import { atom, useAtom } from ‘jotai’;
// 明示的に型を指定
const countAtom = atom(0);
interface User {
id: number;
name: string;
email: string;
}
// interfaceを使ってオブジェクトの型を指定
const userAtom = atom(null); // 初期値はnull、ユーザーがロードされたらUser型
function UserProfile() {
const [user, setUser] = useAtom(userAtom);
// userがnullでない場合にのみプロパティにアクセスできる
if (!user) return
Please load user
;
// user.name や user.email にアクセスする際に型チェックが働く
return (
);
}
“`
派生アトムでも型推論が強力に働くため、複雑な状態の変換や組み合わせでも型安全性を保つことができます。
“`typescript jsx
const usersAtom = atom([]); // ユーザーリストのアトム
// ユーザーリストの数を数える派生アトム(型推論により number となる)
const userCountAtom = atom((get) => get(usersAtom).length);
// 特定のユーザーを検索する派生アトム(型推論により User | undefined となる)
const findUserByIdAtom = atom((get) => (id: number) => {
return get(usersAtom).find(user => user.id === id);
});
function UserCounter() {
const userCount = useAtomValue(userCountAtom);
return
Total users: {userCount}
;
}
“`
このように、JotaiはTypeScript環境での開発において、状態の定義から利用まで一貫した型安全性を提供し、開発効率とコードの信頼性を向上させます。
開発者ツール
Jotaiには公式の開発者ツール「Jotai DevTools」があります。これはRedux DevToolsのように、Atomの現在の値を確認したり、Atomの更新履歴を追跡したり、時間旅行デバッグ(Stateの巻き戻し/再生)を行ったりする機能を提供します。
“`jsx
import { Provider } from ‘jotai’;
import { DevTools } from ‘jotai-devtools’;
import App from ‘./App’;
ReactDOM.render(
{/ Providerの下、アプリケーションコンポーネントの上に配置 /}
,
document.getElementById(‘root’)
);
“`
DevToolsを利用することで、アプリケーションの複雑な状態の流れや、Atom間の依存関係を視覚的に把握しやすくなり、デバッグ作業が効率化されます。
テスト
Jotaiを使用したコンポーネントのテストは比較的容易です。useAtomやuseAtomValueなどのフックは、React Testing Libraryのようなツールを使ってコンポーネントをレンダリングし、実際のAtomを使ってテストできます。
あるいは、テスト用にAtomをモックしたり、Providerを使って特定のAtomの初期値をオーバーライドしたりすることも可能です。
“`javascript
import { atom, useAtomValue } from ‘jotai’;
import { Provider } from ‘jotai’;
import { render } from ‘@testing-library/react’;
const greetingAtom = atom(‘Hello’);
function GreetingDisplay() {
const greeting = useAtomValue(greetingAtom);
return
{greeting}
;
}
test(‘displays default greeting’, () => {
const { getByText } = render(
{/ テスト用のProvider /}
);
expect(getByText(‘Hello’)).toBeInTheDocument();
});
test(‘displays overridden greeting’, () => {
const overriddenGreetingAtom = atom(‘Hi’); // テスト用に別のAtomを作成
const { getByText } = render(
{/ 特定のAtomをオーバーライド /}
);
expect(getByText(‘Bonjour’)).toBeInTheDocument();
});
“`
ProviderのinitialValuesプロパティを使うことで、テスト対象のコンポーネントが利用するAtomに、テスト用の初期値を注入できます。これにより、外部API呼び出しなどを含む非同期アトムのテストや、特定の状態をシミュレートしたテストが容易になります。
パート5:Jotaiの活用例
これまでに説明した概念を踏まえ、Jotaiが実際のアプリケーション開発でどのように活用できるか、いくつかの具体的な例を見てみましょう。
例1:シンプルなカウンター(再掲と詳細)
基本ですが、Jotaiのシンプルさをよく表しています。
“`jsx
// atoms/counter.ts
import { atom } from ‘jotai’;
export const countAtom = atom(0);
export const doubleCountAtom = atom((get) => get(countAtom) * 2);
// components/Counter.tsx
import React from ‘react’;
import { useAtom, useAtomValue, useSetAtom } from ‘jotai’;
import { countAtom, doubleCountAtom } from ‘../atoms/counter’; // Atomをインポート
function CounterDisplay() {
const count = useAtomValue(countAtom); // 値だけ読む
const doubleCount = useAtomValue(doubleCountAtom); // 派生アトムの値を読む
return (
Count: {count}
Double: {doubleCount}
);
}
function CounterButtons() {
const setCount = useSetAtom(countAtom); // セッターだけ読む
return (
setCount(prev => prev + 1)}>Increment
setCount(0)}>Reset
);
}
function CounterApp() {
return (
<>
);
}
export default CounterApp;
// App.tsx (Root)
import { Provider } from ‘jotai’;
import CounterApp from ‘./components/Counter’;
function App() {
return (
);
}
“`
この例では、状態の定義(countAtom, doubleCountAtom)がatomsディレクトリに分離されています。コンポーネントは、必要なAtomだけをインポートして利用します。CounterDisplayは値のみ、CounterButtonsはセッターのみを利用することで、それぞれの関心事が明確になり、不要な再レンダリングも抑制されます。
例2:ToDoリスト(フィルタリング機能付き)
ToDoリストは、アイテムごとの状態管理と、全体の状態に基づいた派生状態(フィルタリングされたリスト、未完了タスク数など)の管理が必要となる良い例です。
“`jsx
// atoms/todos.ts
import { atom } from ‘jotai’;
import { atomFamily } from ‘jotai/utils’;
type Todo = { id: number; text: string; completed: boolean };
type Filter = ‘all’ | ‘active’ | ‘completed’;
// 全ToDoアイテムのリストを保持するアトム
export const todosAtom = atom([]);
// 現在のフィルター設定を保持するアトム
export const filterAtom = atom(‘all’);
// フィルターされたToDoリストの派生アトム
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case ‘active’:
return todos.filter((todo) => !todo.completed);
case ‘completed’:
return todos.filter((todo) => todo.completed);
case ‘all’:
default:
return todos;
}
});
// 未完了タスク数の派生アトム
export const activeTodoCountAtom = atom((get) => {
return get(todosAtom).filter((todo) => !todo.completed).length;
});
// TodoのIDをキーとする、個別の完了状態を管理するアトムファミリー
export const todoCompletedAtomFamily = atomFamily((todoId: number) => atom(false));
// components/TodoApp.tsx
import React, { useState, useRef } from ‘react’;
import { useAtom, useAtomValue, useSetAtom } from ‘jotai’;
import { todosAtom, filterAtom, filteredTodosAtom, activeTodoCountAtom, todoCompletedAtomFamily } from ‘../atoms/todos’;
// ToDo入力フォーム
function TodoInput() {
const setTodos = useSetAtom(todosAtom);
const [text, setText] = useState(”);
const nextId = useRef(0); // 簡単なID管理
const addTodo = () => {
if (text.trim()) {
setTodos(prev => […prev, { id: nextId.current++, text, completed: false }]);
setText(”);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === ‘Enter’) {
addTodo();
}
};
return (
setText(e.target.value)} onKeyPress={handleKeyPress} />
Add Todo
);
}
// 個別ToDoアイテムコンポーネント
function TodoItem({ todoId, text }: { todoId: number; text: string }) {
// アトムファミリーから個別のcompleted状態を取得
const [isCompleted, setIsCompleted] = useAtom(todoCompletedAtomFamily(todoId));
return (
setIsCompleted(prev => !prev)}
/>
{text}
);
}
// ToDoリストコンポーネント
function TodoList() {
// フィルターされたToDoリストを取得
const filteredTodos = useAtomValue(filteredTodosAtom);
return (
{/ filteredTodos は Todo 型の配列 /}
{filteredTodos.map(todo => (
// TodoItem には todo.id と text を渡す
))}
);
}
// フィルターボタンコンポーネント
function TodoFilters() {
const [filter, setFilter] = useAtom(filterAtom);
const activeCount = useAtomValue(activeTodoCountAtom); // 未完了タスク数
return (
setFilter(‘all’)} disabled={filter === ‘all’}>All
setFilter(‘active’)} disabled={filter === ‘active’}>Active ({activeCount})
setFilter(‘completed’)} disabled={filter === ‘completed’}>Completed
);
}
// 全体アプリコンポーネント
function TodoApp() {
return (
);
}
export default TodoApp;
// App.tsx (Root)
import { Provider } from ‘jotai’;
import TodoApp from ‘./components/TodoApp’;
function App() {
return (
);
}
“`
この例では、todosAtom(全リスト)、filterAtom(現在のフィルター)、todoCompletedAtomFamily(個別の完了状態)という3つの独立したAtomを定義しています。filteredTodosAtomやactiveTodoCountAtomは、これらのAtomを組み合わせて計算される派生アトムです。
UIコンポーネントは、必要なAtomや派生アトムだけをuseAtomまたはuseAtomValueで利用します。例えば、TodoItemはtodoCompletedAtomFamily(todoId)という特定のAtomファミリーインスタンスだけに関心を持ちます。TodoFiltersはfilterAtomとactiveTodoCountAtomに関心があります。
これにより、以下のようなメリットが得られます。
関心の分離: 各Atomは特定の状態のみを管理し、各コンポーネントは必要なAtomのみに依存します。
効率的な更新: 例えば、一つのToDoアイテムの完了状態が変更されても、再レンダリングされるのはそのTodoItemコンポーネントと、activeTodoCountAtomやfilteredTodosAtomを利用しているコンポーネントだけです。リスト全体が再レンダリングされるわけではありません。
明確な依存関係: どのAtomがどのAtomに依存しているかが、コード上で明確に定義されます(特に派生アトム)。
例3:非同期データのフェッチと表示
非同期アトムを使ったデータフェッチの例です。
“`jsx
// atoms/data.ts
import { atom } from ‘jotai’;
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
// 投稿リストをフェッチする非同期アトム
export const postsAtom = atom(async () => {
const response = await fetch(‘https://jsonplaceholder.typicode.com/posts’);
if (!response.ok) throw new Error(‘Failed to fetch posts’);
return response.json();
});
// components/PostList.tsx
import React, { Suspense } from ‘react’;
import { useAtomValue } from ‘jotai’;
import { postsAtom } from ‘../atoms/data’;
function PostListContent() {
// 非同期アトムの値を Suspense の中で読む
const posts = useAtomValue(postsAtom);
return (
);
}
function PostList() {
return (
}> {/ Suspense で囲む /}
これらの例から分かるように、Jotaiは様々な状態管理のパターンに対して、Atomという単一の概念と少数のフックを組み合わせることで対応できます。ボイラープレートが少なく、関連するロジックが近くに配置されるため、コードが読みやすく、メンテナンスしやすくなります。
Jotaiの基本的な使い方と応用例を見てきました。ここで改めて、Jotaiがなぜ状態管理を変革すると言われるのか、その哲学と具体的な利点を深掘りし、既存ライブラリと比較してみましょう。
Jotaiの中心的な哲学は「Atomは状態のプリミティブである」という点です。これは、アプリケーションの状態を、小さく独立した多数のAtomの集合体として捉えることを意味します。
結論として、Jotaiは、Reduxの強力な機能とContext APIのシンプルさの良いとこ取りをしつつ、独自の「プリミティブベース」という哲学で状態管理に新たな視点をもたらします。ボイラープレートが少なく、柔軟性が高く、パフォーマンスにも優れており、特にHooksとTypeScriptを積極的に利用している現代のReact開発スタイルと非常に相性が良いです。
多数の小さなAtomや複雑な派生アトムが相互に依存し合っている場合、あるAtomの値がなぜ変わったのか、その変更がどのAtomに影響を与え、最終的にどのコンポーネントが再レンダリングされたのかを追跡するのが、Reduxのような一元ストアのデバッグに比べて最初は難しく感じるかもしれません。Jotai DevToolsが強力な助けとなりますが、それでもAtom間の依存関係グラフを頭の中で整理するスキルは必要になります。
Jotaiのコミュニティは急速に成長していますが、Reduxのような長年の歴史を持つライブラリに比べると、まだ情報量やサードパーティ製の拡張ライブラリは少ないかもしれません。特定の高度な要件(例: SSRにおける複雑な状態のhydrating、特定の永続化要件など)に対して、解決策を見つけるのに少し労力がかかる可能性があります。ただし、主要なユースケースに対応するためのユーティリティ(jotai/utilsなど)は充実してきています。
デフォルトのグローバルストアで多くのユースケースはカバーできますが、テスト容易性や、特定のサブツリーで独立した状態を持ちたい場合などは、Providerを明示的に使用し、そのスコープを管理する必要があります。Context APIほどProviderのネストが問題になることは少ないですが、アプリケーション設計によってはProviderの配置戦略を考慮する必要があります。
多くのモダンなReactアプリケーション、特にミドルサイズまでのアプリケーションや、スタートアップ段階での開発においては、Jotaiは非常に強力で魅力的な選択肢となるでしょう。
Jotaiの「プリミティブベース」という哲学は、状態を小さなAtomとして定義し、それらを組み合わせて複雑な状態やロジックを構築するという、シンプルかつ強力なアプローチを提供します。ReduxのようなボイラープレートやContext APIのパフォーマンス問題を避けつつ、ReactのHooksやSuspense、TypeScriptといったモダンなエコシステムとの高い親和性を持っています。
派生アトムによる効率的な計算値管理、非同期アトムによるデータフェッチのシンプル化、アトムファミリーによる動的なリストアイテム状態管理など、Jotaiは様々なユースケースに対応できる柔軟性を持っています。
Jotaiはまだ比較的新しいライブラリですが、その設計思想と開発者体験の良さから急速にコミュニティでの支持を集めています。多くのプロジェクトで状態管理の課題を解決し、開発効率とコードの品質を向上させる可能性を秘めています。
もしあなたがProps Drilling、Context Hell、Reduxの複雑さに悩んだ経験があるなら、ぜひ一度Jotaiを試してみてください。Atomという新しい状態のプリミティブは、あなたのReactアプリケーションにおける状態管理の考え方を根本から変えるかもしれません。シンプルで柔軟、そしてパワフルなJotaiが、あなたの開発体験をより良いものにすることでしょう。