RecoilのAtomとSelectorが描く、Reactの状態管理とメモ化の美学
こんにちは。日夜、DOMのツリー構造と再描画の最適化に魂を燃やしているフロントエンド・アーキテククトだ。
近年のReactエコシステムは、Server Componentsの台頭や、Zustand、Jotaiといった軽量アトミックステートの台頭により、かつての「Redux一色」だった混沌とした時代から、より洗練されたフェーズへと移行している。
そんな中、Meta(旧Facebook)がReactの内部構造(Fiberアーキテクチャ)と完全にシンクロするように設計した「Recoil」の存在意義を、今一度深く見つめ直す価値は大いにある。特に、AtomとSelectorという2つのプリミティブが織りなすデータフローグラフは、巨大なWebアプリケーションのパフォーマンスと保守性を担保するための極めて美しい解法だ。
今回は、このAtomとSelectorの内部挙動、そして現場の泥臭い最適化の知見を、ギークな視点から徹底的に掘り下げていこう。
—
1. Atom:状態の「最小単位」とFiberツリーからの解放
なぜコンポーネントツリーの「外」に状態を持つべきなのか?
React標準の `useState` や `useReducer` は、コンポーネントのライフサイクルと強固に結びついている。これは局所的な状態には最適だが、アプリケーション全体で共有されるべきグローバルに近い状態(例:認証ユーザー情報、複雑なフィルター条件、選択中のアイテムID)を管理しようとした途端、いわゆる「Prop Drilling(プロップバケツリレー)」の呪縛に囚われるか、無理やりContext APIを持ち出して「Contextが更新されるたびに、配下の全コンポーネントが強制再描画される」というパフォーマンスの悪夢に直面する。
Recoilの Atom は、Reactのコンポーネントツリーから完全に独立した「状態の最小単位(Single Source of Truth)」をメモリ上にポップアップさせる。
import { atom } from ‘recoil’;
// 一意のkeyとデフォルト値を持つAtomの定義
export const userFilterState = atom
key: ‘userFilterState’, // React内で一意である必要があるグローバルキー
default: ”, // 初期値
});
このAtomをコンポーネント内で購読(Subscribe)するには、`useRecoilState` や `useRecoilValue` を使う。ここで重要なのは、「このAtomを購読しているコンポーネントだけが、Atomの値が変化した際にピンポイントで再描画(Re-render)される」という点だ。Context APIのように、関心の無い子孫コンポーネントまで巻き込んで再描画を引き起こすことがない。ブラウザのメインスレッドを無駄なVDOMの差分比較に費やさせないための、極めて理にかなった設計だ。
—
2. Selector:派生状態(Derived State)とDAG(有向非巡回グラフ)の魔法
Atomが「生のデータ」を保持するハコだとすれば、Selectorはそのデータを加工・演算して取り出すための「派生状態(Derived State)」の純粋関数(Pure Function)だ。
Selectorの正体は「メモ化された計算グラフ」
Selectorは、単なる関数の実行結果を返すだけではない。内部でDAG(有向非巡回グラフ)を構築し、依存しているAtomや他のSelectorの値が変化していない限り、前回の計算結果をメモリ(キャッシュ)から即座に返す。いわゆる `memoization` の極致だ。
import { atom, selector } from ‘recoil’;
import { User } from ‘./types’;
// 1. 生のユーザーリストを保持するAtom
export const rawUserListState = atom
key: ‘rawUserListState’,
default: [],
});
// 2. 検索フィルターのAtom
export const userFilterState = atom
key: ‘userFilterState’,
default: ”,
});
// 3. Selector: Atom同士を組み合わせた「派生状態」
export const filteredUserListSelector = selector
key: ‘filteredUserListSelector’,
get: ({ get }) => {
// 依存関係の宣言:ここでgetを呼ぶことで、Recoilは自動的にグラフの依存関係を構築する
const users = get(rawUserListState);
const filter = userFilterState; // あ、危ない!get(userFilterState)と書くべきところをミスると依存関係が壊れるぞ
// 実際の正しいコードはこちら
const currentFilter = get(userFilterState).toLowerCase();
if (!currentFilter) return users;
// 重いフィルタリング処理(メモ化されるため、無関係なAtomが更新されてもここは走らない)
return users.filter(user =>
user.name.toLowerCase().includes(currentFilter) ||
user.email.toLowerCase().includes(currentFilter)
);
},
});
このSelectorの何が美しいかというと、「データ依存関係の管理をプログラマの気合ではなく、ライブラリのトポロジカルソートに完全に委譲できる」という点だ。
—
3. 非同期Selector:Suspenseとの完璧な融合
実務のアプリケーションで避けて通れないのが「非同期処理(APIコールなど)」だ。Reduxで非同期を扱おうとすると、ThunkやSagaといったボイラープレートの山に埋もれ、ミドルウェアのコンフィグで頭を悩ませることになる。
しかし、RecoilのSelectorは非同期関数(`async/await`)をネイティブでサポートしている。さらに、Reactの `Suspense` および `ErrorBoundary` と完全に統合されるため、データフェッチングの状態管理(ローディング中、エラー、成功)をコンポーネントから美しく消し去ることができる。
import { selector } from ‘recoil’;
// 外部APIからユーザー詳細データを取得する非同期Selector
export const userDetailSelector = selector({
key: ‘userDetailSelector’,
// 引数を受け取る場合は family を使うのが定石だが、今回はシンプルに固定IDの例
get: async ({ get }) => {
// 非同期処理が完了するまで、SelectorはPromiseをスローし、ReactのSuspenseが捕捉する
const response = await fetch(‘https://api.example.com/user/current’);
if (!response.ok) {
throw new Error(‘ユーザー情報の取得に失敗しました’);
}
const data = await response.json();
return data; // 解決された値がキャッシュされ、コンポーネントに渡される
},
});
これをコンポーネント側で使うときは、ローディングフラグの管理(`const [loading, setLoading] = useState(true)` など)を書く必要が一切ない。
import React, { Suspense } from ‘react’;
import { useRecoilValue } from ‘recoil’;
import { userDetailSelector } from ‘./userSelectors’;
function UserProfileContent() {
// データが解決されるまで、このコンポーネントのレンダリングはサスペンドする
const user = useRecoilValue(userDetailSelector);
return (
{user.name}
{user.email}
);
}
export default function UserProfile() {
return (
// 親でSuspenseを挟むだけで、宣言的なローディングUIを実現

コメント