Recoilの検討 ~SWRとの併用について考えた~
株式会社IVRy (アイブリー)のエンジニアのkinashiです。
IVRyでは主にフロントエンドを担当しています。
IVRyでは現状、状態管理のライブラリは使っておらず、SWRとReact Contextを使って開発しています。
APIから取得するデータはSWRがcacheとして管理してくれるので、UIのstateをReact Contextで管理していますが、機能が増えていくにつれて次のような悩みも出てきています。
- Contextの管理が煩雑になっている
- 毎回Contextを定義するのが冗長に感じる
今回はReactと同じMeta社が開発しているRecoilについて検討[1]することにしました。
まだ検討段階ではありますが、考えたことをまとめようと思います。
Recoil
Recoilの特徴などは色々なブログなどで紹介されていると思うので割愛しますが、コアコンセプトのページをchatGPT先生に翻訳してもらったので載せておきます。
Recoilは、アトム(共有された状態)からセレクタ(純粋な関数)を通じてReactコンポーネントに流れるデータフローグラフを作成することができます。アトムはコンポーネントが購読することができる状態の単位です。セレクタはこの状態を同期または非同期で変換します。
Atoms
アトムは状態の単位です。更新可能で購読可能です:アトムが更新されると、各購読されたコンポーネントが新しい値とともに再レンダリングされます。ランタイムでも作成することができます。アトムはReactローカルコンポーネントの状態の代わりに使用することができます。同じアトムが複数のコンポーネントから使用される場合、すべてのコンポーネントはその状態を共有します。
Selectors
セレクタは、アトムや他のセレクタを入力として受け入れる純粋な関数です。これらの上流アトムやセレクタが更新されると、セレクタ関数が再評価されます。コンポーネントはアトムのようにセレクタに購読することができ、セレクタが変更されたときに再レンダリングされます。
セレクタは、状態に基づいて派生データを算出するために使用されます。これにより、アトムに最小限の状態が格納され、他のすべてが最小状態からの機能として効率的に計算されるため、冗長な状態を回避できます。セレクタは、どのコンポーネントが必要で、どの状態に依存するかを追跡するため、この機能的なアプローチを非常に効率的にします。
コンポーネントの観点からは、セレクタとアトムは同じインターフェイスを持っており、それらは互いに置き換えることができます。
検討したこと
- React Contextを使って書いているコードをシンプルにできるか
- stateの値を特定の条件で絞込んだselectorが綺麗に書けるか
- stateの更新処理が綺麗に書けるか
React Contextを使って書いているコードをシンプルにできるか
Context
Contextのサンプルコード
import type { AppProps } from 'next/app'
import { createContext, FC, ReactNode } from 'react'
import { useTodoList } from '@/hooks/todoList'
import { Todo } from '@/types'
type ToDoListState = {
todoList: Todo[]
}
const TodoListContext = createContext({} as ToDoListState)
interface TodoListProviderProps {
children: ReactNode
}
const TodoListProvider: FC<TodoListProviderProps> = ({ children }) => {
const { todoList } = useTodoList()
return (
<TodoListContext.Provider
value={{
todoList,
}}
>
{children}
</TodoListContext.Provider>
)
}
function App({ Component, pageProps }: AppProps) {
return (
<TodoListProvider>
<Component {...pageProps} />
</TodoListProvider>
)
}
Recoil
こちらにはデータを取得するコードがないですが、かなりシンプルにできそうです。
import type { AppProps } from 'next/app'
import { RecoilRoot, atom } from 'recoil'
import { Todo } from '@/types'
const todoListState = atom<Todo[]>({
key: 'todoList',
default: [],
})
function App({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
)
}
stateの値を特定の条件で絞込んだselectorが綺麗に書けるか
Contextとの比較はしませんが、selectorもかなりシンプルに書けます。
const todoListState = atom<Todo[]>({
key: 'todoList',
default: [],
})
const selectCompletedTodos = selector({
key: 'selectCompletedTodos',
get: ({ get }) => get(todoListState).filter((todo) => todo.isCompleted),
})
// Component
const TodoList: FC = () => {
const todoList = useRecoilValue(selectCompletedTodos)
return (
<ul className={styles.list}>
{todoList.map((todo) => (
<TodoItem key={todo.id} todoId={todo.id} />
))}
</ul>
)
}
stateの更新処理が綺麗に書けるか
Recoilのドキュメントに useSetRecoilState
を使って値を更新しているサンプルがあります。
function TodoItemCreator() {
const setTodoList = useSetRecoilState(todoListState)
const addItem = () => {
setTodoList((oldTodoList) => [
...oldTodoList,
{
id: getId(),
text: inputValue,
isComplete: false,
},
])
}
useSetRecoilState
を使うと useRecoilState
のsetter関数だけ使うことが出来ます。
Reducerパターンのようなものは提供していないので、各コンポーネントに処理を書くのではなく、stateにどのような変更をする処理があるのか見通しが良いようにactionをhookに切り出しておくと良さそうだと感じました。
Atomという命名からも複雑なstateを1つのAtomに入れるような使い方を想定したライブラリではなさそうです。
const useAddItem = () => {
const setTodoList = useSetRecoilState(todoListState)
return useCallback((todoItem) => {
setTodoList((oldTodoList) => [
...oldTodoList,
todoItem,
])
}, [setTodoList])
}
おまけ
chatGPTにあれこれ聞きながら試していましたが、 useRecoilReducer
という、いかにもありそうでまだこの世にないコードを教えてくれました。もしかしたらそのうち実装されるかもしれません 😂
SWRとの役割分担は?
思考実験的にRecoilに寄せるパターンも試してみました。
❌ SWRで取得したデータをRecoilに詰めて使う
useEffectを使うことでデータを使うときはRecoilだけ意識すれば良くなります。
Recoilのselectorも使えるのが便利そう。
と思ったのですが、よく考えると useSetTodoListEffect
がマウントされているときでないとSWRのmutateが期待通りに動かないことが分かります。
const useSetTodoListEffect = () => {
const { isLoading, data: todoList, error } = useSwr('/api/todo_list', fetchTodoList)
const setTodoList = useSetRecoilState(todoListState)
const setTodoListIsLoading = useSetRecoilState(todoListIsLoadingState)
const setError = useSetRecoilState(errorState)
useEffect(() => {
setTodoListIsLoading(isLoading)
setTodoList(todoList ?? [])
setError(error)
}, [isLoading, todoList, error, setTodoListIsLoading, setTodoList, setError])
}
const TodoList: FC = () => {
useSetTodoListEffect()
const todoList = useRecoilValue(selectCompletedTodos)
const isLoading = useRecoilValue(todoListIsLoadingState)
const error = useRecoilValue(errorState)
if (error) {
// エラーハンドリングしてもいいし、errorStateを監視するモーダルなどがあってもいい
}
if (isLoading) return (
<>Loading...</>
)
return todoList?.length ? (
<ul className={styles.list}>
{todoList.map((todo) => (
<TodoItem key={todo.id} todoId={todo.id} />
))}
</ul>
) : (
<div>Have a good day!</div>
)
}
⭕ APIから取得する値はSWRで管理する
Reduxを長いこと使っていたこともあって、全てのstateを同じインターフェースで扱いたくなってしまいますが、使用しているライブラリの特性を生かした書き方が一番だと改めて思いました。
selectorは関数として切り出しておけば再利用もできますし、こちらの方が断然シンプルですね!
const useTodoList = () => {
const { isLoading, data, error } = useSwr('/api/todo_list', fetchTodoList)
return {
isLoading,
todoList: data ?? [],
error,
}
}
const selectCompleted = (todoList) => {
if (!data) {
return []
}
return todoList.filter(item => item.isCompleted)
}
const TodoList: FC = () => {
const { isLoading, todoList } = useTodoList()
if (isLoading) return (
<>Loading...</>
)
return todoList?.length ? (
<ul className={styles.list}>
{selectCompleted(todoList).map((todo) => (
<TodoItem key={todo.id} todoId={todo.id} />
))}
</ul>
) : (
<div>Have a good day!</div>
)
}
まとめ
ディレクトリの切り方やAtomをどの粒度で分割していくのかなど、まだまだ考えるべきことはありますが、直感的でとても使いやすいツールだと感じました。
Reactのhookもこれから新しいものが出てきそうですし、他のフロントメンバーとも話し合って自分たちのプロダクトに合ったツールを検討していこうと思います。
最後に、IVRyでは一緒に働く仲間を絶賛募集中です!
フロントエンドに限らずバックエンドやAIエンジニアも募集中です!お気軽にご応募ください!
-
Reactの状態管理ライブラリを検討する上で、まず検討対象になるのはReduxだと思いますが、ReduxはIVRyとは別のプロダクトで使っていたことがあるので、ある程度想像がつく為、今回はRecoilについて検討しました。 ↩︎
Discussion