#はじめに
この記事は、React Advent Calendar 2021 19日目の記事です。
個人開発でRecoilを使用しているのですが、Recoilに対する理解が足りておらず、いまいち使いこなせていない感覚がありました。丁度React Advent Calenderがあったので、Reactでの使い方も含めてまとめてしまおうと思い今に至ります。
以前書いた記事[[3]]にも目を通していただけると、よりスムーズに分かるかと思います。
#前提
Reactの基本的なことを理解している
typescriptを使える
コールバックを理解している
必須ではないですが、FluxアーキテクチャやReduxについても理解していると比較的分かりやすいです
#Recoilとは
Facebookが作成中の状態管理ライブラリです。ただ、Reactの状態管理に対する公式の見解ではないので、ReactがRecoilを使用することを推奨している訳ではないです。Reduxなどと比べてシンプルであり、比較的学習コストが低いです。
#Recoilの概念
Recoilの概念はシンプルで、主な登場人物はatomとselectorの2つです。
###Atom
Atomはrecoilにおける主要で最小な単位であり、各コンポーネントではAtomをsubscribeすることでstateを参照することができます。atomが更新されるとsubscribeしている各コンポーネントは新しい値で再レンダリングされます。Atomはatom関数を用いて生成する例を下に示します。
const fontSizeState = atom({
key: 'fontSizeState',
default: 14,
});
[[1]]より引用
Atomには一意なキーが必要で、2つのAtomが同じキーを持つことはエラーになります。そのためキーはグローバルに一意である必要があることに注意してください。コンポーネントからAtomを読み書きするにはuseRecoilStateといhookを使うことで可能です。更新関数だけ、状態だけ、どちらもほしいという場合でhookが分かれており、
useRecoilState():値と更新関数どちらも
useRecoilValue():値のみ
useSetRecoilState():更新関数のみ
となっているので状況に応じて使い分けましょう。
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
return (
<button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
Click to Enlarge
</button>
);
}
[[1]]より引用
###Selector
SelectorはAtomや他のSelectorを入力として受け入れる純粋な関数です。これらの上流のAtomやSelectorが更新されると関数が再評価されます。コンポーネントはAtomと同様にSelectorにsubscribeすることができ、Selectorが変更された時に再レンダリングされます。また、コンポーネントから見るとSelectorとAtomは同じインターフェースを持っているので互いに置き換えることが可能です。下にSelectorの例を示します。
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
[[1]]より引用
getプロパティは実行される関数の中身です。渡されたget引数を使ってAtomや他のSelectorの値にアクセスすることが出来ます。他のAtomやSelectorにアクセスする場合、それらが更新されるとこのSelector(Atom)が再計算されるような依存関係が作成されます。下にSelectorの例を示します。
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
[[1]]より引用
この fontSizeLabelState の例では、Selectorは fontSizeStateというAtomを依存関係として持っています。概念的にはfontSizeLabelStateは入力としてfontSizeStateを取り、出力としてフォーマットされたフォントサイズのラベルを返す純粋な関数のように振る舞います。
基本的にSelectorはuseRecoilValue( )を使って読み込むことができます。これはAtomまたはSelectorを引数として取り、対応する値を返します。fontSizeLabelStateは書き込みができないので、useRecoilState( )は使いません(後でもう少し詳しく説明します)。コンポーネント側での例を下に示します。
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
const fontSizeLabel = useRecoilValue(fontSizeLabelState);
return (
<>
<div>Current font size: {fontSizeLabel}</div>
<button onClick={() => setFontSize(fontSize + 1)} style={{fontSize}}>
Click to Enlarge
</button>
</>
);
}
[[1]]より引用
更新関数なども引数で定義したい場合は下の例のようにsetプロパティを用いることで可能です。
const proxySelector = selector({
key: 'ProxySelector',
get: ({get}) => ({...get(myAtom), extraField: 'hi'}),
set: ({set}, newValue) => set(myAtom, newValue),
});
[[1]]より引用
#RecoilのAPI
Reactでの使い方を考察する前に、もう少しRecoilのAPIを見てみましょう。
###atomFamily
似たようなAtomを使いまわしたい時に使用できます。
const elementPositionStateFamily = atomFamily({
key: 'ElementPosition',
default: [0, 0],
});
function ElementListItem({elementID}) {
const position = useRecoilValue(elementPositionStateFamily(elementID));
return (
<div>
Element: {elementID}
Position: {position}
</div>
);
}
[[1]]より引用
###selectorFamily
selectorFamilyは同一の状態に対して引数を受け取って処理内容を変更したい場合に使えます。具体的には引数などによって処理内容を変更したい場合などが挙げられます。
const myNumberState = atom({
key: 'MyNumber',
default: 2,
});
const myMultipliedState = selectorFamily({
key: 'MyMultipliedNumber',
get: (multiplier) => ({get}) => {
return get(myNumberState) * multiplier;
},
// optional set
set: (multiplier) => ({set}, newValue) => {
set(myNumberState, newValue / multiplier);
},
});
function MyComponent() {
// defaults to 2
const number = useRecoilValue(myNumberState);
// defaults to 200
const multipliedNumber = useRecoilValue(myMultipliedState(100));
return <div>...</div>;
}
[[1]]より引用
###useRecoilValueLoadable
これについてはまず定義を見てもらった方が早いと思います。
function useRecoilValueLoadable<T>(state: RecoilValue<T>): Loadable<T>
LoadableというAPIについてはこちら[[2]]を参照してください。とりあえずオブジェクトであり、stateが現在のローディング状態を、contentsが状態の値やエラーオブジェクトやPromiseを表すという解釈をしておけば大丈夫です。
非同期処理のローディング処理などがこのAPIによって簡潔に書けます。
function UserInfo({userID}) {
const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
switch (userNameLoadable.state) {
case 'hasValue':
return <div>{userNameLoadable.contents}</div>;
case 'loading':
return <div>Loading...</div>;
case 'hasError':
throw userNameLoadable.contents;
}
}
[[1]]より引用
#RecoilをReactでどう使うべきか
上記で述べたことを踏まえてReactでのRecoilの使い方について考察していきます。最初の頃はコンポーネント側で直接Recoil APIを使用していたのですが、atomFamilyなどを使うと記述がかなり冗長になり読みにくかったです。また、コンポーネント側から直接state側を触れてしまうのであまり良くないなと感じました。前回の記事[[1]]の後Twitterでも指摘を受けたのですが、Atomなどのキーを一意に保たないといけないという点で安全性が低いなと感じました。
何か良い方法がないかと探していると、LINE様のエンジニアブログにRecoilに関する記事がありました[[4]]。この記事でも同様の問題を指摘しており、キーの一意性を担保するためにenumを、意図しない状態への操作を防ぐためにAtomなどをexportしないという対応をされていました。とりあえず[[4]]の記事を参考に簡単なtodoアプリケーションを作成してみました。大部分は[[4]]の記事で紹介されているコードと同じですが、一部変えています。
export enum recoilAtomKeys {
TODO_STATE = "todoState",
}
export enum recoilSelectorKeys {
TODO_TODOS = "Todo_todos",
TODO_TODO_ITEM = "Todo_todoItem",
}
import {
atom,
selector,
selectorFamily,
useRecoilValue,
useSetRecoilState,
} from "recoil";
import { recoilAtomKeys, recoilSelectorKeys } from "../assets/data/recoilKeys";
import { useCallback } from "react";
type TodoItem = {
id: number;
label: string;
};
type TodoState = {
todos: TodoItem[];
};
const todoState = atom<TodoState>({
key: recoilAtomKeys.TODO_STATE,
default: {
todos: [],
},
});
type TodoActions = {
useAddTodoItem: () => { addTodo: (label: string) => void };
};
const createNewId = () => {
return 1;
};
export const todoActions: TodoActions = {
// Todoを追加する
useAddTodoItem: () => {
const setState = useSetRecoilState(todoState);
const addTodo = (label: string) =>
setState((prev) => {
const newItem: TodoItem = {
id: createNewId(),
label,
};
return {
...prev,
todos: [...prev.todos, newItem],
};
});
return { addTodo: useCallback(addTodo, [createNewId]) };
},
};
type TodoSelectors = {
useGetTodos: () => { todos: TodoItem[] };
useGetTodoItem: (id: number) => TodoItem | undefined;
};
// すべてのTodoを読み出す
const todosSelector = selector<TodoItem[]>({
key: recoilSelectorKeys.TODO_TODOS,
get: ({ get }) => get(todoState).todos,
});
// IDで指定したTodoを読み出す
const todoItemSelector = selectorFamily<TodoItem | undefined, number>({
key: recoilSelectorKeys.TODO_TODO_ITEM,
get:
(id) =>
({ get }) => {
const todos = get(todoState).todos;
return todos.find((v) => v.id === id);
},
});
export const todoSelectors: TodoSelectors = {
useGetTodos: () => {
const todos = useRecoilValue(todosSelector);
return { todos };
},
useGetTodoItem: (id: number) => useRecoilValue(todoItemSelector(id)),
};
import { useState, VFC } from "react";
import { todoActions, todoSelectors } from "../../store/todoState";
import { Header } from "../organisms/Header";
export const RecoilPage: VFC = () => {
const [text, setText] = useState("");
const { addTodo } = todoActions.useAddTodoItem();
const { todos } = todoSelectors.useGetTodos();
return (
<>
<Header />
<h1>未完了のタスク</h1>
{todos.map((todo) => {
return <div>{"id: " + todo.id + "label: " + todo.label}</div>;
})}
<div>タスクの追加</div>
<input onChange={(e) => setText(e.target.value)} />
<button onClick={() => addTodo(text)}>追加</button>
</>
);
};
[[4]]を参考にして作成
RecoilPage.tsx
を見て貰えばわかる通り、コンポーネント側には全くRecoilの要素は出てきていません。todoState.ts
内では直接キーを書かずに、インポートすることで一意性を担保しています。書き込みをする際はActionsという関数を返す関数を定義することで、イベントハンドラーなどでも使用可能になっています。読み取りの際はSelectorsを通して行っています。
#Recoilのディレクトリ構成
上記のようにすることで、先ほど述べた問題点は確かに解決されました。しかし、大規模開発であるほどstateファイルが肥大化して読みにくくなることが予想されます。そのためにファイルの分割を行なっていきます。ここからはあくまで僕の個人的な意見であり、みんなこれにした方が良いというものではないです。
まず、Atomsなどをエクスポートしないという原則ですが、この原則があるとファイル分割ができないので廃止します。ただし、importするのはカスタムフックのみとし、コードレビュー時に確認することでhooks以外での操作を禁止することにします。自分は下記のようにファイルを分割しました。
.
├── App.tsx
├── assets
│ ├── data
│ │ ├── pathData.ts
│ │ └── recoilKeys.ts
│ └── type
│ └── stateType
│ └── todoStateType.ts
├── components
│ ├── atoms
│ ├── molecules
│ ├── organisms
│ │ └── Header.tsx
│ ├── pages
│ │ └── RecoilPage1.tsx
│ └── templates
├── hooks
│ └── todoState
│ ├── useAddTodoItem.ts
│ ├── useGetTodoItem.ts
│ └── useGetTodos.ts
├── index.tsx
├── router
│ └── ComponentsRouter.tsx
└── store
├── atomFamily.ts
└── todoState
├── atom.ts
└── selector.ts
AtomicDesignをベースにファイル分割を行いました。container層を入れるかどうかは今回は議論しません。
色々考えた結果、ducksパターンでもre-ducksパターンでもない、機能ごとにファイルを分割するという結論になりました。これ自体は別にreduxでも出来るのですが、reduxの場合はaction、reducerなども加わってきてもっとファイルが増えがちです。これくらいの規模なら機能ごとに分割した方がコンポーネント指向の良さが出るのではないかと思いました。テスト関連に関しては考察しきれてないのでなんとも言えません。
atomFamilyは使いまわしたいのでstoreディレクトリ直下に別で配置しました。atomFamilyを使う場合は各状態のatomで一意なキーを設定してからエクスポートする形を取ります。
このようなディレクトリ構成にすることで、ファイルが肥大しづらく、hooksにrecoil要素を閉じ込めることができます。
#おわりに
recoilの良いところはやはりreduxと比べてシンプルなところですね。action,reducer,dispatchなどの概念が出てくると最初は理解に時間がかかる人も多いと思います。あそこら辺はhooksで丸ごと抽象化してしまうのが良いのではと思っているんですが、どうなんでしょうか。これからrecoilを本格的に使ってみて、所感をまた記事にしたいと思います。
#参考文献
[1]:https://recoiljs.org/
[2]:https://recoiljs.org/docs/api-reference/core/Loadable
[3]:https://qiita.com/it_tsumugi/items/210da6b7e3ec115fc237
[4]:https://engineering.linecorp.com/ja/blog/line-sec-frontend-using-recoil-to-get-a-safe-and-comfortable-state-management/
[[1]]:Recoil 公式
[[2]]:class Loadable
[[3]]:useContextが不便だったのでrecoilで置き換えた
[[4]]:【LINE証券 FrontEnd】Recoilを使って安全快適な状態管理を手に入れた話