Recoil の使い方 — React向け状態管理ライブラリ完全ガイド
一言でいうと
RecoilはFacebook(Meta)が開発した、React専用の状態管理ライブラリです。Atomという単位で状態を定義し、Selectorで派生データを計算するというシンプルなモデルで、Reactの並行レンダリングとも親和性の高い設計になっています。
⚠️ 重要な注意: Recoilは「experimental(実験的)」と公式に位置づけられており、2023年以降メンテナンスがほぼ停止しています。新規プロジェクトでの採用は慎重に検討してください。本記事はv0.7.7時点の情報です。
どんな時に使う?
- コンポーネント間で状態を共有したいが、Context APIのパフォーマンス問題を避けたい時 — Recoilは購読しているAtomが変更されたコンポーネントだけを再レンダリングします。
- 派生データ(computed state)を宣言的に管理したい時 — Selectorを使えば、複数のAtomから計算される値をキャッシュ付きで定義できます。
- 非同期データの取得と状態管理を統合したい時 — SelectorはPromiseを返すことができ、React Suspenseと組み合わせて非同期データを扱えます。
インストール
# npm
npm install recoil
# yarn
yarn add recoil
# pnpm
pnpm add recoil
前提条件: React 16.8以上(Hooks対応)が必要です。React 18でも動作しますが、Strict Modeで警告が出る場合があります。
基本的な使い方
Recoilの最も基本的なパターンは、Atomで状態を定義し、コンポーネントからHooksで読み書きするという流れです。
// App.tsx
import React from 'react';
import { RecoilRoot, atom, useRecoilState } from 'recoil';
// 1. Atomを定義する
const counterState = atom<number>({
key: 'counterState', // アプリ全体でユニークなキー
default: 0,
});
// 2. コンポーネントからAtomを使う
const Counter: React.FC = () => {
const [count, setCount] = useRecoilState(counterState);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>+1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
};
// 3. RecoilRootでアプリをラップする
const App: React.FC = () => {
return (
<RecoilRoot>
<Counter />
</RecoilRoot>
);
};
export default App;
ポイントは3つだけです:
RecoilRootでアプリ全体(または一部)をラップatom()で状態の単位を定義useRecoilState()でuseStateと同じ感覚で読み書き
よく使うAPI
1. atom() — 状態の最小単位を定義
import { atom } from 'recoil';
interface User {
id: string;
name: string;
email: string;
}
export const currentUserState = atom<User | null>({
key: 'currentUserState',
default: null,
});
// デフォルト値にAtomやSelectorも指定可能
export const themeState = atom<'light' | 'dark'>({
key: 'themeState',
default: 'light',
});
key はアプリケーション全体でユニークである必要があります。重複するとランタイムエラーになります。
2. selector() — 派生データを定義
import { selector } from 'recoil';
import { currentUserState } from './atoms';
// 同期Selector
export const userDisplayNameState = selector<string>({
key: 'userDisplayNameState',
get: ({ get }) => {
const user = get(currentUserState);
return user ? user.name : 'ゲスト';
},
});
// 非同期Selector(API呼び出し)
export const userProfileState = selector<User>({
key: 'userProfileState',
get: async ({ get }) => {
const user = get(currentUserState);
if (!user) throw new Error('User not found');
const response = await fetch(`/api/users/${user.id}/profile`);
return response.json();
},
});
非同期Selectorを使う場合は、コンポーネントを React.Suspense でラップします:
import React, { Suspense } from 'react';
import { useRecoilValue } from 'recoil';
import { userProfileState } from './selectors';
const UserProfile: React.FC = () => {
const profile = useRecoilValue(userProfileState);
return <div>{profile.name}</div>;
};
// 使用側
const Page: React.FC = () => (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
3. useRecoilValue() / useSetRecoilState() — 読み取り専用・書き込み専用Hooks
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { counterState } from './atoms';
import { userDisplayNameState } from './selectors';
// 読み取り専用(再レンダリングは値変更時のみ)
const DisplayName: React.FC = () => {
const displayName = useRecoilValue(userDisplayNameState);
return <span>{displayName}</span>;
};
// 書き込み専用(このコンポーネントは値変更で再レンダリングされない)
const IncrementButton: React.FC = () => {
const setCount = useSetRecoilState(counterState);
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
};
useSetRecoilState は値を読まないため、Atomの値が変わってもこのコンポーネントは再レンダリングされません。パフォーマンス最適化に有効です。
4. atomFamily() / selectorFamily() — パラメータ付きAtom・Selector
import { atomFamily, selectorFamily } from 'recoil';
// IDごとに独立したAtomを生成
export const todoItemState = atomFamily<Todo, string>({
key: 'todoItemState',
default: (id: string) => ({
id,
text: '',
completed: false,
}),
});
// パラメータ付きSelector
export const todoLengthState = selectorFamily<number, string>({
key: 'todoLengthState',
get: (id: string) => ({ get }) => {
const todo = get(todoItemState(id));
return todo.text.length;
},
});
// コンポーネントでの使用
const TodoItem: React.FC<{ id: string }> = ({ id }) => {
const [todo, setTodo] = useRecoilState(todoItemState(id));
return (
<input
value={todo.text}
onChange={(e) => setTodo({ ...todo, text: e.target.value })}
/>
);
};
5. useRecoilCallback() — 命令的にAtomを読み書き
import { useRecoilCallback } from 'recoil';
import { todoItemState } from './atoms';
const TodoActions: React.FC = () => {
const saveTodos = useRecoilCallback(
({ snapshot, set }) =>
async (ids: string[]) => {
// 複数のAtomをまとめて読み取り
const todos = await Promise.all(
ids.map((id) => snapshot.getPromise(todoItemState(id)))
);
// APIに保存
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(todos),
});
// 保存後に状態を更新
ids.forEach((id) => {
set(todoItemState(id), (prev) => ({ ...prev, saved: true }));
});
},
[]
);
return <button onClick={() => saveTodos(['1', '2', '3'])}>保存</button>;
};
useRecoilCallback はレンダリングサイクル外でAtomにアクセスでき、バッチ処理や副作用の実行に便利です。
類似パッケージとの比較
| 特徴 | Recoil | Jotai | Zustand | Redux Toolkit |
|---|---|---|---|---|
| 設計思想 | Atom + Selector | Atom(Recoil inspired) | Store(単一) | Store + Slice |
| バンドルサイズ | ~79KB | ~8KB | ~3KB | ~40KB |
| React専用 | ✅ | ✅ | ❌(React以外も可) | ❌ |
| 非同期サポート | Suspense統合 | Suspense統合 | ミドルウェア | RTK Query |
| TypeScript | 良好 | 優秀 | 優秀 | 優秀 |
| メンテナンス状況 | ⚠️ ほぼ停止 | ✅ 活発 | ✅ 活発 | ✅ 活発 |
| 学習コスト | 低〜中 | 低 | 低 | 中〜高 |
| DevTools | 限定的 | 良好 | 良好 | 優秀 |
Recoilからの移行先として最も自然なのはJotaiです。Atom-basedの設計思想を引き継ぎつつ、バンドルサイズが大幅に小さく、活発にメンテナンスされています。
注意点・Tips
1. keyの重複に注意
// ❌ keyが重複するとランタイムエラー
const stateA = atom({ key: 'myState', default: 0 });
const stateB = atom({ key: 'myState', default: '' }); // Duplicate atom key!
// ✅ プレフィックスで名前空間を分ける
const stateA = atom({ key: 'counter/myState', default: 0 });
const stateB = atom({ key: 'user/myState', default: '' });
2. React 18 Strict Modeでの警告
React 18のStrict Modeでは、開発時にコンポーネントが2回マウントされるため、Recoilが警告を出すことがあります。本番ビルドでは発生しませんが、気になる場合は以下で抑制できます:
<RecoilRoot>
{/* override: falseでネストされたRecoilRootの挙動を制御 */}
</RecoilRoot>
3. atomFamilyのメモリリーク
atomFamily で生成されたAtomは自動的にガベージコレクションされません。大量のIDで使用する場合は注意が必要です。
// 不要になったAtomをリセットする
const cleanup = useRecoilCallback(({ reset }) => (id: string) => {
reset(todoItemState(id));
});
4. Selectorのキャッシュ
Selectorはデフォルトで依存するAtomが変わらない限りキャッシュされます。非同期Selectorで毎回フェッチしたい場合は、依存にリクエストIDのようなAtomを含めます:
const requestIdState = atom({ key: 'requestId', default: 0 });
const dataState = selector({
key: 'dataState',
get: async ({ get }) => {
get(requestIdState); // これが変わるとキャッシュが無効化される
const res = await fetch('/api/data');
return res.json();
},
});
// リフェッチしたい時
const refresh = useSetRecoilState(requestIdState);
refresh((id) => id + 1);
5. プロジェクトの現状を理解する
Recoilのリポジトリ(facebookexperimental/Recoil)は2023年以降コミットがほぼなく、IssueやPRも放置されている状態です。既存プロジェクトで使用中の場合は問題ありませんが、新規プロジェクトではJotaiやZustandを検討することを強く推奨します。
まとめ
Recoilは、Atom + Selectorというシンプルなメンタルモデルで、Reactの状態管理を直感的に行えるライブラリです。useState の延長線上で使える学習コストの低さと、Suspenseとの統合による非同期データ管理が大きな魅力でした。
ただし、現在はメンテナンスが事実上停止しているため、新規採用は推奨できません。既存プロジェクトで利用中の方は、Jotaiへの段階的な移行を計画することをおすすめします。Recoilで学んだAtom-basedの考え方はJotaiにそのまま活かせます。