はい、承知いたしました。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}
);
}
“`
利点:
- 非常にシンプルで理解しやすい。
- コンポーネントのスコープ内に閉じているため、他の部分への影響がない。
- 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 (
);
}
// アプリケーションの一部
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}
);
}
“`
利点:
- 予測可能性: 状態の更新が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}
);
}
// 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}
{/ セッター関数を使って値を更新 /}
);
}
“`
このコードを見てください。どこかで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 (
);
}
“`
このように、useAtom
、useAtomValue
、useSetAtom
を使い分けることで、コンポーネントが必要なものだけをAtomから取得し、不必要な再レンダリングを最小限に抑えることができます。
Providerについて
JotaiはデフォルトではReactのContext APIを利用して状態を管理します。そのため、状態を共有するためには、コンポーネントツリーのルートにProvider
を配置するのが最も簡単な方法です。
“`javascript
import { Provider } from ‘jotai’;
import App from ‘./App’; // jotaiを使うコンポーネントが含まれる
ReactDOM.render(
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 (
/>
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 (