【テクニカル・上級編】 RecoilのAtomとSelectorの概念 – React実践ガイド

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 (

);
}

export default function UserProfile() {
return (
// 親でSuspenseを挟むだけで、宣言的なローディングUIを実現
Loading user profile…

}>


);
}

この設計思想の素晴らしいところは、「ビジネスロジック(データの取得・加工)」と「UIの表現(ローディング状態)」の関心が完全に分離されている点だ。

—

4. 上級エンジニアが知るべき「落とし穴」とパフォーマンス最適化

さて、ここまではRecoilの甘い部分を語ってきたが、現場の最前線でこれを運用するにあたって、知っておくべき「泥臭い現実」と最適化のノウハウにも言及しておこう。

1. キーの一意性(Key Uniqueness)の管理地獄

RecoilのすべてのAtomとSelectorには、グローバルで一意の `key` 文字列を付与する必要がある。
アプリケーションが大規模化し、コード分割(Code Splitting)や動的インポートを行うようになると、キーの重複や命名規則の崩壊が起きやすい。
規約として `[ファイル名]/[変数名]` のようなプレフィックスを強制するLintルールを自作するか、TypeScriptの型安全なファクトリー関数を用意してキーの衝突をコンパイル時に防ぐアーキテクチャが必須となる。

2. Selectorのキャッシュ汚染とメモリリーク

Selectorの計算結果はメモリ上のキャッシュ(Map構造)に保持される。
もし、Selector内で動的に生成されたオブジェクトや配列をそのまま返却し続け、かつその入力値のパターンが無数に存在する場合(例えば、ミリ秒単位のタイムスタンプや、完全にユニークなUUIDを依存関係に含めてしまった場合)、メモリ使用量が右肩上がりに増大するメモリリークを引き起こす。
Selectorの入力(依存するAtom/Selector)は、可能な限りプリミティブな値、あるいは正規化されたIDの配列に絞るべきだ。

3. 書き込み可能な Selector(Writable Selector)の活用

Selectorはデフォルトでは「読み取り専用(Getterのみ)」だが、`set` プロパティを定義することで、「派生状態への書き込みを、背後にある複数のAtomへの更新に変換する」という強力なパターンの実装が可能になる。

import { atom, selector } from ‘prop-types’; // 冗談、実際は recoil から

export const firstNameState = atom({ key: ‘firstName’, default: ‘John’ });
export const lastNameState = atom({ key: ‘lastName’, default: ‘Doe’ });

// 読み書き両方をサポートするSelector
export const fullNameSelector = selector({
key: ‘fullNameSelector’,
get: ({ get }) => {
return `${get(firstNameState)} ${get(lastNameState)}`;
},
set: ({ set, reset }, newValue) => {
// フルネームで書き込まれたら、スペースで分割してそれぞれのAtomを更新する!
// TypeScriptの型ガードは省略しているが、実務では厳密に行うこと
if (typeof newValue === ‘string’) {
const [first, last] = newValue.split(‘ ‘);
set(firstNameState, first || ”);
set(lastNameState, last || ”);
}
},
});

このパターンを使いこなせば、コンポーネント側は「単一の `fullName` というフォーム入力値」を扱うだけで、内部の複雑なデータ構造(ファーストネームとラストネームの分割保存)をカプセル化できる。ドメインモデルの複雑性をUI層から完全に隠蔽できるのだ。

—

まとめ

RecoilのAtomとSelectorは、単なる「グローバルステート管理ライブラリの選択肢」ではない。
ReactのFiberアーキテクチャの本質である「宣言的UI」と「コンポーネントの純粋性」を極限まで押し上げるための、極めて洗練された数学的・アーキテクチャ的アプローチだ。

  • Atomで状態の「真実のソース」をツリーの外部に切り出し、不要な再描画を防ぐ。
  • Selectorでデータの依存関係グラフ(DAG)を構築し、重い計算や非同期処理を美しくメモ化・カプセル化する。

モダンなフロントエンド開発において、「なんとなく動く」コードから「意図通りに最適化され、拡張性に満ちた」コードへとステップアップしたいのであれば、このAtomとSelectorが奏でるデータフローの美しさを、ぜひ次のプロジェクトのアーキテクチャに取り入れてみてほしい。

ブラウザのCPU使用率が下がり、コードの美しさに酔いしれること請け合いだ。

コメント

タイトルとURLをコピーしました