recoil の使い方

Recoil - A state management library for React

v0.7.7/週MIT状態管理
AI生成コンテンツ

この記事はAIによって生成されました。内容の正確性は保証されません。最新の情報は公式ドキュメントをご確認ください。

Recoil の使い方 — React向け状態管理ライブラリ完全ガイド

一言でいうと

RecoilはFacebook(Meta)が開発した、React専用の状態管理ライブラリです。Atomという単位で状態を定義し、Selectorで派生データを計算するというシンプルなモデルで、Reactの並行レンダリングとも親和性の高い設計になっています。

⚠️ 重要な注意: Recoilは「experimental(実験的)」と公式に位置づけられており、2023年以降メンテナンスがほぼ停止しています。新規プロジェクトでの採用は慎重に検討してください。本記事はv0.7.7時点の情報です。


どんな時に使う?

  1. コンポーネント間で状態を共有したいが、Context APIのパフォーマンス問題を避けたい時 — Recoilは購読しているAtomが変更されたコンポーネントだけを再レンダリングします。
  2. 派生データ(computed state)を宣言的に管理したい時 — Selectorを使えば、複数のAtomから計算される値をキャッシュ付きで定義できます。
  3. 非同期データの取得と状態管理を統合したい時 — 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にアクセスでき、バッチ処理や副作用の実行に便利です。


類似パッケージとの比較

特徴RecoilJotaiZustandRedux Toolkit
設計思想Atom + SelectorAtom(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にそのまま活かせます。