STERFIELD

Recoil から Redux へ移行するときにやったこと

Recoil から Redux へ移行するときにやったこと

はじめに

2023 年 10 月ごろに、Recoil と Jotai に関する記事を投稿しましたが、2023 年 3 月以降、Recoil の更新が進められていない状況で、React 19 以降では正常に動作しない見込みとなってきました[参考]。
そのため、これまで Recoil を使ってきた React のプロジェクトにおいて、別の状態管理ライブラリへの移行が必要となり、対応を進めています。
その選択肢の一つとして、Redux への移行を行ったので、そのときの方法を Recoil と Redux のコードを比べながら、通常の状態と、API との通信を伴う非同期の状態の移行に分けて、ご紹介します。
ここでご紹介する方法は、Redux は、Redux Toolkit と React Redux の利用を前提にしています。

インストール

Recoil から Redux へ移行する場合、Redux Toolkit と React Redux を使うことで、Recoil のときと同じようなコードの書き方で、スムーズに移行できるので、そちらをインストールします。

1npm i @reduxjs/toolkit react-redux

ファイルの準備

以下はファイル構成の例です。

1redux 2├ hooks/ // React用のHooksを格納する 3│ ├ normalExample.ts 4│ ├ familyExample.ts 5│ ┗ asyncExample.ts 6├ slices/ // ReduxのSliceを格納する 7│ ├ normalExampleSlice.ts 8│ ├ familyExampleSlice.ts 9│ ┗ asyncExampleSlice.ts 10┗ store.ts // Reduxのstore

通常の状態

Recoil

1import { 2 atom, 3 useRecoilValue, 4 useResetRecoilState, 5 useSetRecoilState, 6} from "recoil"; 7 8// Recoilのatomを設定 9export const normalExampleState = atom<string>({ 10 key: "normalExample", 11 default: "", 12}); 13 14// get state 15export const useNormalExampleValue = () => useRecoilValue(normalExampleState); 16 17// set state 18export const useSetNormalExampleState = (key) => 19 useSetRecoilState(loadingState); 20 21// reset state 22export const useResetNormalExampleState = () => 23 useResetRecoilState(normalExampleState);

上記のような状態を Recoil で設定していた場合、Redux Toolkit と React Redux を使って以下のように置き換えることができます。
ご覧いただくとわかりますが、reducer による action の設定や Root の store.ts への設定のところが Recoil にはない記述ですが、それ以外は似たコードで構成できることがわかります。

Redux

redux/slices/normalExampleSlice.ts

1import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 2 3const initialState = ""; 4 5// ReduxのSliceを設定 6export const normalExampleSlice = createSlice({ 7 name: "normalExample", 8 initialState, 9 reducers: { 10 // set 11 setNormalExample: (state, action: PayloadAction<string>) => { 12 state.normalExample = action.payload; 13 }, 14 // reset 15 resetNormalExample: (state) => { 16 state.normalExample = ""; 17 }, 18 }, 19}); 20 21export const { setNormalExample, resetNormalExample } = 22 normalExampleSlice.actions; 23 24export default normalExampleSlice.reducer;

redux/store.ts

1import { configureStore } from "@reduxjs/toolkit"; 2import { TypedUseSelectorHook, useSelector } from "react-redux"; 3import normalExample from "./slices/normalExampleSlice"; 4 5// 用意したSliceをstoreに登録する 6export const store = configureStore({ 7 reducer: { 8 normalExample, 9 }, 10}); 11 12export type AppStore = typeof store; 13 14export type RootState = ReturnType<AppStore["getState"]>; 15 16export type AppDispatch = AppStore["dispatch"]; 17 18// useSelectorでは型が引き継がれないので、TypeScriptでは型を引き継げるuseAppSelectorを用意する 19export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

React 用の Hooks は、以下のように React Redux の useDispatch と useSelector(TypeScript の場合は store.ts で定義した useAppSelector)を利用して以下のように構成できます。

redux/hooks/normalExampleSlice.ts

1import { useDispatch } from "react-redux"; 2import { useCallback } from "react"; 3import { 4 setNormalExample, 5 resetNormalExample, 6} from "@/redux/slices/normalExampleSlice"; 7import { useAppSelector, type RootState } from "../store"; 8 9// get state 10export const useNormalExampleValue = () => 11 useAppSelector((state: RootState) => state.normalExample); 12 13// set state 14export const useSetNormalExampleState = () => { 15 const dispatch = useDispatch(); 16 return (value: string) => { 17 dispatch(setNormalExample(value)); 18 }; 19}; 20 21// reset state 22export const useResetNormalExampleState = () => { 23 const dispatch = useDispatch(); 24 return () => { 25 dispatch(resetNormalExample(value)); 26 }; 27};

AtomFamily からの移行

Recoil では、atomFamily という、同じ型の状態をキーごとに再利用するための便利な機能が用意されていますが、Redux Toolkit の Slice でも、以下のように構成することで、似た機能を実装できます。

Recoil

1import { atomFamily } from "recoil"; 2 3import { 4 atom, 5 useRecoilValue, 6 useResetRecoilState, 7 useSetRecoilState, 8} from "recoil"; 9 10// RecoilのatomFamilyを設定 11 12export const familyExampleState = atomFamily<string, string>({ 13 key: "familyExample", 14 default: null 15}); 16 17// get state 18export const useNormalExampleValue = (key: string) => useRecoilValue(familyExampleState(key)); 19 20// set state 21export const useSetNormalExampleState = (key: string) => 22 useSetRecoilState(familyExampleState(key)); 23 24// reset state 25export const useResetNormalExampleState = (key: string) => 26 useResetRecoilState(familyExampleState(key));

Redux

redux/slices/familyExampleSlice.ts

1import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 2 3export interface FamilyExampleState { 4 [key: string]: string | null; 5} 6 7const initialState: FamilyExampleState = { 8 default: null, 9}; 10 11const familyExampleSlice = createSlice({ 12 name: "familyExample", 13 initialState, 14 reducers: { 15 // set 16 setFamilyExample( 17 state, 18 action: PayloadAction<{ key: string; value: string }> 19 ) { 20 state[action.payload.key] = action.payload.value; 21 }, 22 // reset 23 resetFamilyExample(state, action: PayloadAction<string>) { 24 state[action.payload] = null; 25 }, 26 }, 27}); 28 29export const { setFamilyExample, resetFamilyExample } = 30 familyExampleSlice.actions; 31 32export default familyExampleSlice.reducer;

redux/hooks/familyExampleSlice.ts

1import { useDispatch, shallowEqual } from "react-redux"; 2import { type RootState, useAppSelector } from "../store"; 3import { 4 setFamilyExample, 5 resetFamilyExample, 6} from "../slices/familyExampleSlice"; 7 8export const useFamilyExampleValue = (key: string) => 9 useAppSelector((state: RootState) => state.familyExample[key], shallowEqual); 10 11export const useSetFamilyExampleState = (key: string) => { 12 const dispatch = useDispatch(); 13 return (value: string) => { 14 dispatch(setFamilyExample({ key, value })); 15 }; 16}; 17 18export const useResetFamilyExampleState = (key: string) => { 19 const dispatch = useDispatch(); 20 return () => { 21 dispatch(resetFamilyExample(key)); 22 }; 23};

API 通信を伴う非同期の状態

API からのデータ取得など、非同期を伴う場合、Redux への移行には少しなれがひつようかもしれません。 以下のような Recoil では selector で API からのデータ取得を伴う状態を構成していた場合を移行する場合の例です。

Recoil

1import { 2 atom, 3 selector, 4 useSetRecoilState, 5 useRecoilValue, 6 useRecoilRefresher_UNSTABLE, 7} from "recoil"; 8import { apolloClient } from "@/api/apollo-client"; 9import { exampleQuery } from "@/api/query/exampleQuery"; 10 11export const asyncExampleState = selector({ 12 key: "asyncExample", 13 get: async () => { 14 const { data, errors } = await apolloClient.query({ 15 query: exampleQuery, 16 fetchPolicy: "no-cache", 17 }); 18 if (errors) { 19 throw errors; 20 } 21 return data.example; 22 }, 23}); 24 25// get state 26export const useAsyncExampleValue = () => useRecoilValue(asyncExampleState); 27 28// reset state 29export const useResetAsyncExampleState = () => 30 useRecoilRefresher_UNSTABLE(asyncExampleState);

Redux

Redux Toolkit で非同期を伴う Slice を作成する場合、createAsyncThunk と createSlice の extraReducers で構成します。

redux/slices/asyncExampleSlice.ts

1import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; 2import type { ApolloError } from "@apollo/client"; 3import { apolloClient } from "@/api/apollo-client"; 4import { exampleQuery, type Example } from "@/api/query/exampleQuery"; 5import type { RootState } from "../store"; 6 7// API用の状態の型を定義 8const initialState: { 9 data: Example[]; 10 status: "idle" | "loading" | "succeeded" | "failed"; 11 error: ApolloError; 12} = { 13 data: [], 14 status: "idle", 15 error: null, 16}; 17 18// 非同期用のreducerを設定 19export const fetchAsyncExample = createAsyncThunk< 20 Example[], 21 void, 22 { state: RootState } 23>("asyncExample/fetchAsyncExample", async (_, { rejectWithValue }) => { 24 const { data, errors } = await apolloClient.query({ 25 query: exampleQuery, 26 fetchPolicy: "no-cache", 27 }); 28 29 if (error) { 30 return rejectWithValue(error); 31 } 32 33 return data.example; 34}); 35 36// SliceにextraReducersを使って非同期用のreducerを紐づける 37export const asyncExampleSlice = createSlice({ 38 name: "asyncExample", 39 initialState, 40 reducers: {}, 41 extraReducers: (builder) => { 42 builder 43 .addCase(fetchAsyncExample.pending, (state) => { 44 state.status = "loading"; 45 }) 46 .addCase(fetchAsyncExample.fulfilled, (state, action) => { 47 state.status = "succeeded"; 48 state.data = action.payload; 49 }) 50 .addCase(fetchAsyncExample.rejected, (state, action) => { 51 state.status = "failed"; 52 state.error = action.payload as any; 53 }); 54 }, 55}); 56 57export default asyncExampleSlice.reducer;

redux/hooks/asyncExampleSlice.ts

1import { useDispatch, shallowEqual } from "react-redux"; 2import { type RootState, useAppSelector } from "../store"; 3import { fetchAsyncExample } from "../slices/asyncExampleSlice"; 4 5// get state 6export const useAsyncExampleValue = () => { 7 const dispatch: AppDispatch = useDispatch(); 8 const { data, status } = useAppSelector( 9 (state: RootState) => state.example, 10 shallowEqual 11 ); 12 13 // 状態が"idle"の時はfetchを実行する 14 useEffect(() => { 15 if (status === "idle") { 16 dispatch(fetchAsyncExample()); 17 } 18 }, [status, dispatch]); 19 20 return data; 21}; 22 23// reset state 24export const useResetNormalExampleState = () => { 25 const dispatch = useDispatch(); 26 return () => { 27 dispatch(fetchAsyncExample()); 28 }; 29};

移行途中で Recoil と依存する状態を連携させる場合

移行途中で共存させなければならない場合、以下のように Recoil に依存する状態を Redux と連携できます。 ただし、Recoil 同士の状態と違い、Redux の状態の変化を Recoil は自動で検知してくれないので、Redux の状態を変更するときに、Recoil で Reset や Refresh を実行する必要があることの注意が必要です。

1import { selector } from "recoil"; 2import { store } from "@/redux/store"; 3import { apolloClient } from "@/api/apollo-client"; 4import { exampleQuery, type Example } from "@/api/query/exampleQuery"; 5 6export const exampleState = selector({ 7 key: "exampleState", 8 get: async ({ get }) => { 9 const keyword = store.getState().keyword; 10 11 const { data, errors } = await apolloClient.query({ 12 query: exampleQuery, 13 variables: { 14 keyword, 15 }, 16 fetchPolicy: "no-cache", 17 errorPolicy: "all", 18 }); 19 if (errors) { 20 throw errors; 21 } 22 return data.example; 23 }, 24 cachePolicy_UNSTABLE: { 25 eviction: "most-recent", 26 }, 27});

まとめ

Recoil の atom や selector の書き方と Redux Toolkit の createSlice の書き方が似ていることもあり、意外とコードの移行の親和性が高いと感じました。
ただ、非同期関係では Redux において createAsyncThunk を利用するなど、Recoil にはない独特の記述も必要で、慣れが必要な部分も実感しました。

参考リンク

Author Profile

著者近影

NINOMIYA

Webデザイナー兼コーダー出身のフロントエンド開発者です。 UXデザインやチーム開発の効率化など、勉強中です。

SHARE

合わせて読みたい