メインコンテンツにスキップ

モダン Redux への移行

学習内容
  • 従来の「手書き」の Redux ロジックを Redux Toolkit を使用するように近代化する方法
  • 従来の React-Redux connect コンポーネントをフック API を使用するように近代化する方法
  • TypeScript を使用する Redux ロジックおよび React-Redux コンポーネントを近代化する方法

概要

Redux は 2015 年から存在しており、Redux コードの記述に関する推奨パターンは長年にわたって大幅に変更されています。React が createClass から React.Component へ、そしてフックを備えた関数コンポーネントへと進化してきたのと同じように、Redux も手動ストア設定 + オブジェクトスプレッドを使用した手書きリデューサー + React-Redux の connect から、Redux Toolkit の configureStore + createSlice + React-Redux のフック API へと進化してきました。

多くのユーザーは、これらの「モダン Redux」パターンが存在する前から存在している古い Redux コードベースで作業しています。これらのコードベースを今日の推奨されるモダン Redux パターンに移行すると、コードベースが大幅に小さくなり、保守が容易になります。

良いニュースは、**古い Redux コードと新しい Redux コードが共存し、連携しながら、コードをモダン Redux に少しずつ段階的に移行できる**ということです!

このページでは、既存のレガシー Redux コードベースを近代化するために使用できる一般的なアプローチと手法について説明します。

情報

Redux Toolkit + React-Redux フックを使用した「モダン Redux」が Redux の使用をどのように簡素化するかについての詳細は、次の追加リソースを参照してください。

Redux Toolkit による Redux ロジックの近代化

Redux ロジックを移行する一般的なアプローチは次のとおりです。

  • 既存の手動 Redux ストア設定を Redux Toolkit の configureStore に置き換えます。
  • 既存のスライスリデューサーとそれに関連付けられたアクションを選択します。それらを RTK の createSlice に置き換えます。一度に 1 つのリデューサーに対して繰り返します。
  • 必要に応じて、既存のデータフェッチロジックを RTK Query または createAsyncThunk に置き換えます。
  • 必要に応じて、createListenerMiddlewarecreateEntityAdapter などの RTK の他の API を使用します。

**最初に、レガシーな createStore 呼び出しを configureStore に置き換える必要があります**。これは 1 回限りのステップであり、既存のリデューサーとミドルウェアはすべてそのまま動作し続けます。configureStore には、誤ったミューテーションやシリアライズ不可能な値などの一般的なミスに対する開発モードチェックが含まれているため、それらを配置すると、コードベースでこれらのミスが発生している箇所を特定するのに役立ちます。

情報

この一般的なアプローチの実際の動作については、**Redux Fundamentals、パート 8: Redux Toolkit を使用したモダン Redux** を参照してください。

configureStore によるストアの設定

一般的なレガシー Redux ストア設定ファイルでは、いくつかの異なるステップを実行します。

  • スライスリデューサーをルートリデューサーに結合します。
  • ミドルウェアエンハンサーを作成します。通常は、thunk ミドルウェアと、場合によっては redux-logger などの開発モードの他のミドルウェアを使用します。
  • Redux DevTools エンハンサーを追加し、エンハンサーを組み合わせて構成します。
  • createStore を呼び出します。

既存のアプリケーションでのこれらのステップは次のようになります。

src/app/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import thunk from 'redux-thunk'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer,
})

const middlewareEnhancer = applyMiddleware(thunk)

const composeWithDevTools =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

const composedEnhancers = composeWithDevTools(middlewareEnhancer)

const store = createStore(rootReducer, composedEnhancers)

これらのステップはすべて、Redux Toolkit の configureStore API の 1 回の呼び出しに置き換えることができます。.

RTK の configureStore は元の createStore メソッドをラップし、ストアのセットアップのほとんどを自動的に処理します。実際、効果的に 1 つのステップに短縮できます。

基本的なストア設定: src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import postsReducer from '../reducers/postsReducer'
import usersReducer from '../reducers/usersReducer'

// Automatically adds the thunk middleware and the Redux DevTools extension
const store = configureStore({
// Automatically calls `combineReducers`
reducer: {
posts: postsReducer,
users: usersReducer,
},
})

configureStore の 1 回の呼び出しですべての作業が完了しました。

  • combineReducers を呼び出して、postsReducerusersReducer をルートリデューサー関数に結合しました。このルートリデューサー関数は、{posts, users} のようなルート状態を処理します。
  • createStore を呼び出して、そのルートリデューサーを使用して Redux ストアを作成しました。
  • thunk ミドルウェアを自動的に追加し、applyMiddleware を呼び出しました。
  • 状態の誤ったミューテーションなどの一般的なミスをチェックするミドルウェアを自動的に追加しました。
  • Redux DevTools 拡張機能接続を自動的にセットアップしました。

追加のミドルウェアの追加、thunk ミドルウェアへの extra 引数の渡し、永続化されたルートリデューサーの作成など、ストアのセットアップに追加のステップが必要な場合は、それも実行できます。次に、組み込みのミドルウェアをカスタマイズし、Redux-Persist をオンにするより大きな例を示します。これは、configureStore の操作オプションの一部を示しています。

詳細な例: 永続化およびミドルウェアを使用したカスタムストア設定

この例では、Redux ストアをセットアップする際に実行可能な一般的なタスクをいくつか示します。

  • リデューサーを個別に結合します (他のアーキテクチャ上の制約により必要な場合があります)。
  • 追加のミドルウェアを条件付きおよび無条件に追加します。
  • API サービス層などの「追加の引数」を thunk ミドルウェアに渡します。
  • シリアライズ不可能なアクションタイプに対する特別な処理が必要な Redux-Persist ライブラリを使用します。
  • prod で devtools をオフにし、開発時に追加の devtools オプションを設定します。

これらはすべて*必須*ではありませんが、実際のコードベースで頻繁に表示されます。

カスタムストア設定: src/app/store.js
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import { PersistGate } from 'redux-persist/integration/react'
import logger from 'redux-logger'

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'
import { api } from '../features/api/apiSlice'
import { serviceLayer } from '../features/api/serviceLayer'

import stateSanitizerForDevtools from './devtools'
import customMiddleware from './someCustomMiddleware'

// Can call `combineReducers` yourself if needed
const rootReducer = combineReducers({
posts: postsReducer,
users: usersReducer,
[api.reducerPath]: api.reducer,
})

const persistConfig = {
key: 'root',
version: 1,
storage,
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
// Can create a root reducer separately and pass that in
reducer: rootReducer,
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware({
// Pass in a custom `extra` argument to the thunk middleware
thunk: {
extraArgument: { serviceLayer },
},
// Customize the built-in serializability dev check
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}).concat(customMiddleware, api.middleware)

// Conditionally add another middleware in dev
if (process.env.NODE_ENV !== 'production') {
middleware.push(logger)
}

return middleware
},
// Turn off devtools in prod, or pass options in dev
devTools:
process.env.NODE_ENV === 'production'
? false
: {
stateSanitizer: stateSanitizerForDevtools,
},
})

createSlice によるリデューサーとアクション

一般的なレガシー Redux コードベースでは、リデューサーロジック、アクションクリエイター、およびアクションタイプが別々のファイルに分散しており、これらのファイルはタイプごとに異なるフォルダーにあることがよくあります。リデューサーロジックは、switch ステートメントと、オブジェクトスプレッドおよび配列マッピングを使用した手書きのイミュータブルな更新ロジックを使用して記述されます。

src/constants/todos.js
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'
src/actions/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

export const addTodo = (id, text) => ({
type: ADD_TODO,
text,
id,
})

export const toggleTodo = (id) => ({
type: TOGGLE_TODO,
id,
})
src/reducers/todos.js
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO: {
return state.concat({
id: action.id,
text: action.text,
completed: false,
})
}
case TOGGLE_TODO: {
return state.map((todo) => {
if (todo.id !== action.id) {
return todo
}

return {
...todo,
completed: !todo.completed,
}
})
}
default:
return state
}
}

Redux Toolkit の createSlice API は、リデューサー、アクション、およびイミュータブルな更新の記述に関するすべての「ボイラープレート」を排除するように設計されました。

Redux Toolkit では、そのレガシーコードに複数の変更が加えられます。

  • createSlice は、手書きのアクションクリエイターとアクションタイプを完全に排除します。
  • action.textaction.id のような一意の名前の付いたすべてのフィールドは、個別の値として、またはそれらのフィールドを含むオブジェクトとして action.payload に置き換えられます。
  • 手書きのイミュータブルな更新は、Immer のおかげでリデューサー内の「ミューテーション」ロジックに置き換えられます。
  • コードのタイプごとに別々のファイルを用意する必要はありません。
  • 特定のレデューサーの*すべての*ロジックを単一の「スライス」ファイルに含めるように教えています。
  • 「コードのタイプ」ごとにフォルダーを分けるのではなく、「機能」ごとにファイルを整理し、関連するコードを同じフォルダーに配置することをお勧めします。
  • 理想的には、リデューサーとアクションの名前は過去形を使用し、ADD_TODO のような命令的な「今すぐこれを行う」ではなく、todoAdded のように「発生したこと」を表す必要があります。

定数、アクション、およびリデューサーのこれらの別々のファイルは、すべて単一の「スライス」ファイルに置き換えられます。近代化されたスライスファイルは次のようになります。

src/features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit'

const initialState = []

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// Give case reducers meaningful past-tense "event"-style names
todoAdded(state, action) {
const { id, text } = action.payload
// "Mutating" update syntax thanks to Immer, and no `return` needed
state.todos.push({
id,
text,
completed: false,
})
},
todoToggled(state, action) {
// Look for the specific nested object to update.
// In this case, `action.payload` is the default field in the action,
// and can hold the `id` value - no need for `action.id` separately
const matchingTodo = state.todos.find(
(todo) => todo.id === action.payload,
)

if (matchingTodo) {
// Can directly "mutate" the nested object
matchingTodo.completed = !matchingTodo.completed
}
},
},
})

// `createSlice` automatically generated action creators with these names.
// export them as named exports from this "slice" file
export const { todoAdded, todoToggled } = todosSlice.actions

// Export the slice reducer as the default export
export default todosSlice.reducer

dispatch(todoAdded('牛乳を買う')) を呼び出すと、todoAdded アクションクリエイターに渡した単一の値が、自動的に action.payload フィールドとして使用されます。複数の値を渡す必要がある場合は、dispatch(todoAdded({id, text})) のようにオブジェクトとして渡します。または、複数の別々の引数を受け入れて payload フィールドを作成するために、createSlice リデューサー内の「prepare」表記を使用することもできます。prepare 表記は、アクションクリエイターが各項目の固有の ID を生成するなど、追加の作業を行っていた場合にも役立ちます。

Redux Toolkit は、フォルダーとファイルの構造やアクションの名前付けについて特に関心を持っていませんが、これらは、保守しやすく理解しやすいコードにつながるため、推奨されるベストプラクティスです。

RTK Query によるデータフェッチ

React + Redux アプリでの一般的なレガシーデータフェッチには、多くの可動部分とコードタイプが必要です。

  • 「リクエスト開始」、「リクエスト成功」、および「リクエスト失敗」アクションを表すアクションクリエイターとアクションタイプ。
  • アクションをディスパッチし、非同期リクエストを行うための thunk。
  • ローディングステータスを追跡し、キャッシュされたデータを保存するリデューサー。
  • ストアからそれらの値を読み取るセレクター。
  • クラスコンポーネントの componentDidMount または関数コンポーネントの useEffect のいずれかを介して、マウント後にコンポーネントで thunk をディスパッチします。

これらは通常、多くの異なるファイルに分割されます。

src/constants/todos.js
export const FETCH_TODOS_STARTED = 'FETCH_TODOS_STARTED'
export const FETCH_TODOS_SUCCEEDED = 'FETCH_TODOS_SUCCEEDED'
export const FETCH_TODOS_FAILED = 'FETCH_TODOS_FAILED'
src/actions/todos.js
import axios from 'axios'
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED,
} from '../constants/todos'

export const fetchTodosStarted = () => ({
type: FETCH_TODOS_STARTED,
})

export const fetchTodosSucceeded = (todos) => ({
type: FETCH_TODOS_SUCCEEDED,
todos,
})

export const fetchTodosFailed = (error) => ({
type: FETCH_TODOS_FAILED,
error,
})

export const fetchTodos = () => {
return async (dispatch) => {
dispatch(fetchTodosStarted())

try {
// Axios is common, but also `fetch`, or your own "API service" layer
const res = await axios.get('/todos')
dispatch(fetchTodosSucceeded(res.data))
} catch (err) {
dispatch(fetchTodosFailed(err))
}
}
}
src/reducers/todos.js
import {
FETCH_TODOS_STARTED,
FETCH_TODOS_SUCCEEDED,
FETCH_TODOS_FAILED,
} from '../constants/todos'

const initialState = {
status: 'uninitialized',
todos: [],
error: null,
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_STARTED: {
return {
...state,
status: 'loading',
}
}
case FETCH_TODOS_SUCCEEDED: {
return {
...state,
status: 'succeeded',
todos: action.todos,
}
}
case FETCH_TODOS_FAILED: {
return {
...state,
status: 'failed',
todos: [],
error: action.error,
}
}
default:
return state
}
}
src/selectors/todos.js
export const selectTodosStatus = (state) => state.todos.status
export const selectTodos = (state) => state.todos.todos
src/components/TodosList.js
import { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { fetchTodos } from '../actions/todos'
import { selectTodosStatus, selectTodos } from '../selectors/todos'

export function TodosList() {
const dispatch = useDispatch()
const status = useSelector(selectTodosStatus)
const todos = useSelector(selectTodos)

useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])

// omit rendering logic here
}

多くのユーザーは redux-saga ライブラリを使用してデータフェッチを管理している可能性があり、その場合、サガをトリガーするために使用される*追加の*「シグナル」アクションタイプと、thunk の代わりにこのサガファイルがある可能性があります。

src/sagas/todos.js
import { put, takeEvery, call } from 'redux-saga/effects'
import {
FETCH_TODOS_BEGIN,
fetchTodosStarted,
fetchTodosSucceeded,
fetchTodosFailed,
} from '../actions/todos'

// Saga to actually fetch data
export function* fetchTodos() {
yield put(fetchTodosStarted())

try {
const res = yield call(axios.get, '/todos')
yield put(fetchTodosSucceeded(res.data))
} catch (err) {
yield put(fetchTodosFailed(err))
}
}

// "Watcher" saga that waits for a "signal" action, which is
// dispatched only to kick off logic, not to update state
export function* fetchTodosSaga() {
yield takeEvery(FETCH_TODOS_BEGIN, fetchTodos)
}

そのコードはすべてRedux Toolkit の「RTK Query」データフェッチとキャッシュレイヤーに置き換えることができます!

RTK Query は、データフェッチを管理するために一切のアクション、サンク、リデューサー、セレクター、またはエフェクトを記述する必要性をなくします。(実際には、内部的にこれらすべてのツールを使用しています。)さらに、RTK Query は、ローディング状態の追跡、リクエストの重複排除、およびキャッシュデータのライフサイクル管理(不要になった期限切れデータの削除を含む)を行います。

移行するには、単一の RTK Query「APIスライス」定義を設定し、生成されたリデューサーとミドルウェアをストアに追加します

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// Fill in your own server starting URL here
baseUrl: '/',
}),
endpoints: (build) => ({}),
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

// Import the API object
import { api } from '../features/api/apiSlice'
// Import any other slice reducers as usual here
import usersReducer from '../features/users/usersSlice'

export const store = configureStore({
reducer: {
// Add the generated RTK Query "API slice" caching reducer
[api.reducerPath]: api.reducer,
// Add any other reducers
users: usersReducer,
},
// Add the RTK Query API middleware
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
})

次に、フェッチおよびキャッシュしたい特定のデータを表す「エンドポイント」を追加し、各エンドポイント用に自動生成された React フックをエクスポートします

src/features/api/apiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
baseQuery: fetchBaseQuery({
// Fill in your own server starting URL here
baseUrl: '/',
}),
endpoints: (build) => ({
// A query endpoint with no arguments
getTodos: build.query({
query: () => '/todos',
}),
// A query endpoint with an argument
userById: build.query({
query: (userId) => `/users/${userId}`,
}),
// A mutation endpoint
updateTodo: build.mutation({
query: (updatedTodo) => ({
url: `/todos/${updatedTodo.id}`,
method: 'POST',
body: updatedTodo,
}),
}),
}),
})

export const { useGetTodosQuery, useUserByIdQuery, useUpdateTodoMutation } = api

最後に、コンポーネントでフックを使用します

src/features/todos/TodoList.js
import { useGetTodosQuery } from '../api/apiSlice'

export function TodoList() {
const { data: todos, isFetching, isSuccess } = useGetTodosQuery()

// omit rendering logic here
}

createAsyncThunk を使用したデータフェッチ

データフェッチには、特に RTK Query を使用することをお勧めします。ただし、一部のユーザーからは、まだその段階に進む準備ができていないという意見も寄せられています。その場合は、RTK の createAsyncThunk を使用して、手書きのサンクとリデューサーのボイラープレートの一部を少なくとも削減できます。これは、アクションクリエーターとアクションタイプを自動的に生成し、リクエストを行うために提供した非同期関数を呼び出し、プロミスのライフサイクルに基づいてそれらのアクションをディスパッチします。createAsyncThunk を使用した同じ例は、次のようになります。

src/features/todos/todosSlice
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import axios from 'axios'

const initialState = {
status: 'uninitialized',
todos: [],
error: null,
}

const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
// Just make the async request here, and return the response.
// This will automatically dispatch a `pending` action first,
// and then `fulfilled` or `rejected` actions based on the promise.
// as needed based on the
const res = await axios.get('/todos')
return res.data
})

export const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// any additional "normal" case reducers here.
// these will generate new action creators
},
extraReducers: (builder) => {
// Use `extraReducers` to handle actions that were generated
// _outside_ of the slice, such as thunks or in other slices
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
// Pass the generated action creators to `.addCase()`
.addCase(fetchTodos.fulfilled, (state, action) => {
// Same "mutating" update syntax thanks to Immer
state.status = 'succeeded'
state.todos = action.payload
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed'
state.todos = []
state.error = action.error
})
},
})

export default todosSlice.reducer

また、セレクターを記述し、useEffect フックで fetchTodos サンクを自分でディスパッチする必要があります。

createListenerMiddleware を使用したリアクティブロジック

多くの Redux アプリには、特定のアクションまたは状態の変化をリッスンし、それに応じて追加のロジックを実行する「リアクティブ」スタイルのロジックがあります。これらの動作は、redux-saga または redux-observable ライブラリを使用して実装されることがよくあります。

これらのライブラリは、さまざまなタスクに使用されます。基本的な例として、アクションをリッスンし、1秒待機してから追加のアクションをディスパッチする saga および epic は、次のようになります。

src/sagas/ping.js
import { delay, put, takeEvery } from 'redux-saga/effects'

export function* ping() {
yield delay(1000)
yield put({ type: 'PONG' })
}

// "Watcher" saga that waits for a "signal" action, which is
// dispatched only to kick off logic, not to update state
export function* pingSaga() {
yield takeEvery('PING', ping)
}
src/epics/ping.js
import { filter, mapTo } from 'rxjs/operators'
import { ofType } from 'redux-observable'

const pingEpic = (action$) =>
action$.pipe(ofType('PING'), delay(1000), mapTo({ type: 'PONG' }))
src/app/store.js
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { combineEpics, createEpicMiddleware } from 'redux-observable';

// skip reducers

import { pingEpic } from '../sagas/ping'
import { pingSaga } from '../epics/ping

function* rootSaga() {
yield pingSaga()
}

const rootEpic = combineEpics(
pingEpic
);

const sagaMiddleware = createSagaMiddleware()
const epicMiddleware = createEpicMiddleware()

const middlewareEnhancer = applyMiddleware(sagaMiddleware, epicMiddleware)

const store = createStore(rootReducer, middlewareEnhancer)

sagaMiddleware.run(rootSaga)
epicMiddleware.run(rootEpic)

RTK の「リスナー」ミドルウェアは、よりシンプルな API、より小さなバンドルサイズ、およびより優れた TS サポートで、sagas および observables を置き換えるように設計されています。

saga および epic の例は、次のようにリスナーミドルウェアで置き換えることができます。

src/app/listenerMiddleware.js
import { createListenerMiddleware } from '@reduxjs/toolkit'

// Best to define this in a separate file, to avoid importing
// from the store file into the rest of the codebase
export const listenerMiddleware = createListenerMiddleware()

export const { startListening, stopListening } = listenerMiddleware
src/features/ping/pingSlice.js
import { createSlice } from '@reduxjs/toolkit'
import { startListening } from '../../app/listenerMiddleware'

const pingSlice = createSlice({
name: 'ping',
initialState,
reducers: {
pong(state, action) {
// state update here
},
},
})

export const { pong } = pingSlice.actions
export default pingSlice.reducer

// The `startListening()` call could go in different files,
// depending on your preferred app setup. Here, we just add
// it directly in a slice file.
startListening({
// Match this exact action type based on the action creator
actionCreator: pong,
// Run this effect callback whenever that action is dispatched
effect: async (action, listenerApi) => {
// Listener effect functions get a `listenerApi` object
// with many useful methods built in, including `delay`:
await listenerApi.delay(1000)
listenerApi.dispatch(pong())
},
})
src/app/store.js
import { configureStore } from '@reduxjs/toolkit'

import { listenerMiddleware } from './listenerMiddleware'

// omit reducers

export const store = configureStore({
reducer: rootReducer,
// Add the listener middleware _before_ the thunk or dev checks
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
})

Redux ロジックの TypeScript の移行

TypeScript を使用する従来の Redux コードは、通常、型の定義に非常に冗長なパターンに従います。特に、コミュニティの多くのユーザーは、個々のアクションごとに TS 型を手動で定義し、dispatch に実際に渡すことができる特定のアクションを制限しようとする「アクションタイプのユニオン」を作成することにしました。

私たちは、これらのパターンに反対することを明確かつ強く推奨します!

src/actions/todos.ts
import { ADD_TODO, TOGGLE_TODO } from '../constants/todos'

// ❌ Common pattern: manually defining types for each action object
interface AddTodoAction {
type: typeof ADD_TODO
text: string
id: string
}

interface ToggleTodoAction {
type: typeof TOGGLE_TODO
id: string
}

// ❌ Common pattern: an "action type union" of all possible actions
export type TodoActions = AddTodoAction | ToggleTodoAction

export const addTodo = (id: string, text: string): AddTodoAction => ({
type: ADD_TODO,
text,
id,
})

export const toggleTodo = (id: string): ToggleTodoAction => ({
type: TOGGLE_TODO,
id,
})
src/reducers/todos.ts
import { ADD_TODO, TOGGLE_TODO, TodoActions } from '../constants/todos'

interface Todo {
id: string
text: string
completed: boolean
}

export type TodosState = Todo[]

const initialState: TodosState = []

export default function todosReducer(
state = initialState,
action: TodoActions,
) {
switch (action.type) {
// omit reducer logic
default:
return state
}
}
src/app/store.ts
import { createStore, Dispatch } from 'redux'

import { TodoActions } from '../actions/todos'
import { CounterActions } from '../actions/counter'
import { TodosState } from '../reducers/todos'
import { CounterState } from '../reducers/counter'

// omit reducer setup

export const store = createStore(rootReducer)

// ❌ Common pattern: an "action type union" of all possible actions
export type RootAction = TodoActions | CounterActions
// ❌ Common pattern: manually defining the root state type with each field
export interface RootState {
todos: TodosState
counter: CounterState
}

// ❌ Common pattern: limiting what can be dispatched at the types level
export type AppDispatch = Dispatch<RootAction>

Redux Toolkit は、TS の使用を大幅に簡素化するように設計されており、私たちの推奨事項には、可能な限り型を推論することが含まれています!

標準の TypeScript の設定と使用ガイドラインに従い、まずストアファイルを設定して、ストア自体から AppDispatch 型と RootState 型を直接推論します。これにより、サンクをディスパッチする機能など、ミドルウェアによって追加された dispatch への変更が正しく含まれ、スライスの状態定義を変更したり、スライスを追加したりするたびに RootState 型が更新されます。

app/store.ts
import { configureStore } from '@reduxjs/toolkit'
// omit any other imports

const store = configureStore({
reducer: {
todos: todosReducer,
counter: counterReducer,
},
})

// Infer the `RootState` and `AppDispatch` types from the store itself

// Inferred state type: {todos: TodosState, counter: CounterState}
export type RootState = ReturnType<typeof store.getState>

// Inferred dispatch type: Dispatch & ThunkDispatch<RootState, undefined, UnknownAction>
export type AppDispatch = typeof store.dispatch

各スライスファイルは、独自のスライス状態の型を宣言してエクスポートする必要があります。次に、PayloadAction 型を使用して、createSlice.reducers 内の action 引数の型を宣言します。生成されたアクションクリエーターには、受け入れる引数の正しい型と、返す action.payload の型も含まれます。

src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface Todo {
id: string
text: string
completed: boolean
}

// Declare and export a type for the slice's state
export type TodosState = Todo[]

const initialState: TodosState = []

const todosSlice = createSlice({
name: 'todos',
// The `state` argument type will be inferred for all case reducers
// from the type of `initialState`
initialState,
reducers: {
// Use `PayloadAction<YourPayloadTypeHere>` for each `action` argument
todoAdded(state, action: PayloadAction<{ id: string; text: string }>) {
// omit logic
},
todoToggled(state, action: PayloadAction<string>) {
// omit logic
},
},
})

React-Redux による React コンポーネントの最新化

コンポーネントでの React-Redux の使用を移行する一般的なアプローチは、次のとおりです。

  • 既存の React クラスコンポーネントを関数コンポーネントに移行する
  • コンポーネントでの useSelector フックと useDispatch フックの使用に connect ラッパーを置き換える

これは、コンポーネント単位で個別に実行できます。connect を使用するコンポーネントとフックを使用するコンポーネントは、同時に共存できます。

このページでは、クラスコンポーネントから関数コンポーネントへの移行プロセスについては説明しませんが、React-Redux に固有の変更に焦点を当てます。

connect からフックへの移行

React-Redux の connect API を使用する典型的な従来のコンポーネントは、次のようになります。

src/features/todos/TodoListItem.js
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId,
} from './todosSlice'

// A `mapState` function, possibly using values from `ownProps`,
// and returning an object with multiple separate fields inside
const mapStateToProps = (state, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state),
}
}

// Several possible variations on how you might see `mapDispatch` written:

// 1) a separate function, manual wrapping of `dispatch`
const mapDispatchToProps = (dispatch) => {
return {
todoDeleted: (id) => dispatch(todoDeleted(id)),
todoToggled: (id) => dispatch(todoToggled(id)),
}
}

// 2) A separate function, wrapping with `bindActionCreators`
const mapDispatchToProps2 = (dispatch) => {
return bindActionCreators(
{
todoDeleted,
todoToggled,
},
dispatch,
)
}

// 3) An object full of action creators
const mapDispatchToProps3 = {
todoDeleted,
todoToggled,
}

// The component, which gets all these fields as props
function TodoListItem({ todo, activeTodoId, todoDeleted, todoToggled }) {
// rendering logic here
}

// Finished with the call to `connect`
export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

React-Redux フック API では、connect 呼び出しと mapState/mapDispatch 引数がフックに置き換えられます!

  • mapState で返される個々のフィールドは、個別の useSelector 呼び出しになります
  • mapDispatch を介して渡される各関数は、コンポーネント内で定義された個別のコールバック関数になります
src/features/todos/TodoListItem.js
import { useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import {
todoAdded,
todoToggled,
selectTodoById,
selectActiveTodoId,
} from './todosSlice'

export function TodoListItem({ todoId }) {
// Get the actual `dispatch` function with `useDispatch`
const dispatch = useDispatch()

// Select values from the state with `useSelector`
const activeTodoId = useSelector(selectActiveTodoId)
// Use prop in scope to select a specific value
const todo = useSelector((state) => selectTodoById(state, todoId))

// Create callback functions that dispatch as needed, with arguments
const handleToggleClick = () => {
dispatch(todoToggled(todoId))
}

const handleDeleteClick = () => {
dispatch(todoDeleted(todoId))
}

// omit rendering logic
}

異なる点の 1 つは、connect が、受信した stateProps+dispatchProps+ownProps が変更されない限り、ラップされたコンポーネントのレンダリングを防止することでレンダリングパフォーマンスを最適化したことです。フックはコンポーネントの内部にあるため、それを実行できません。React の通常の再帰的レンダリング動作を防止する必要がある場合は、コンポーネントを React.memo(MyComponent) でラップしてください。

コンポーネントの TypeScript の移行

connect の主な欠点の 1 つは、正しく型指定することが非常に難しく、型宣言が非常に冗長になることです。これは、高階コンポーネントであり、その API の柔軟性の高さ(4 つの引数、すべてオプション、それぞれに複数の可能なオーバーロードとバリエーションがある)が原因です。

コミュニティは、さまざまな複雑さのレベルで、これを処理する方法について複数のバリエーションを考え出しました。低いレベルでは、一部の使用法では、mapState()state を型指定し、コンポーネントのすべてのプロップスの型を計算する必要がありました。

簡単な connect TS の例
import { connect } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId,
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state),
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled,
}

type TodoListItemProps = TodoListItemOwnProps &
ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps

function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled,
}: TodoListItemProps) {}

export default connect(mapStateToProps, mapDispatchToProps)(TodoListItem)

特に mapDispatch のオブジェクトとしての使用は、サンクが含まれている場合に失敗するため、危険でした。

その他のコミュニティで作成されたパターンでは、dispatch: Dispatch<RootActions> 型を渡すために、mapDispatch を関数として宣言し、bindActionCreators を呼び出すことや、ラップされたコンポーネントが受け取るすべてのプロップスの型を手動で計算し、それらをジェネリックとして connect に渡すことを含む、大幅に多くのオーバーヘッドが必要でした。

わずかに優れた代替案の 1 つは、v7.x で @types/react-redux に追加された ConnectedProps<T> 型で、これにより、connect からコンポーネントに渡されるすべてのプロップスの型を推論できるようになりました。そのためには、推論が正しく機能するように、connect の呼び出しを 2 つの部分に分割する必要がありました

ConnectedProps<T> TS の例
import { connect, ConnectedProps } from 'react-redux'
import { RootState } from '../../app/store'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId,
} from './todosSlice'

interface TodoListItemOwnProps {
todoId: string
}

const mapStateToProps = (state: RootState, ownProps) => {
return {
todo: selectTodoById(state, ownProps.todoId),
activeTodoId: selectActiveTodoId(state),
}
}

const mapDispatchToProps = {
todoDeleted,
todoToggled,
}

// Call the first part of `connect` to get the function that accepts the component.
// This knows the types of the props returned by `mapState/mapDispatch`
const connector = connect(mapStateToProps, mapDispatchToProps)
// The `ConnectedProps<T> util type can extract "the type of all props from Redux"
type PropsFromRedux = ConnectedProps<typeof connector>

// The final component props are "the props from Redux" + "props from the parent"
type TodoListItemProps = PropsFromRedux & TodoListItemOwnProps

// That type can then be used in the component
function TodoListItem({
todo,
activeTodoId,
todoDeleted,
todoToggled,
}: TodoListItemProps) {}

// And the final wrapped component is generated and exported
export default connector(TodoListItem)

React-Redux フック API は、TypeScript での使用がはるかに簡単です! コンポーネントのラッピング、型推論、およびジェネリックのレイヤーを処理する代わりに、フックは引数を受け取り、結果を返す単純な関数です。やり取りする必要があるのは、RootStateAppDispatch の型だけです。

標準の TypeScript の設定と使用ガイドラインに従い、特にフックの「事前型指定済み」エイリアスを設定することを教えています。これにより、正しい型が組み込まれ、アプリでこれらの事前型指定済みのフックのみを使用します。

まず、フックを設定します

src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

次に、コンポーネントで使用します

src/features/todos/TodoListItem.tsx
import { useAppSelector, useAppDispatch } from '../../app/hooks'
import {
todoToggled,
todoDeleted,
selectTodoById,
selectActiveTodoId,
} from './todosSlice'

interface TodoListItemProps {
todoId: string
}

function TodoListItem({ todoId }: TodoListItemProps) {
// Use the pre-typed hooks in the component
const dispatch = useAppDispatch()
const activeTodoId = useAppSelector(selectActiveTodoId)
const todo = useAppSelector((state) => selectTodoById(state, todoId))

// omit event handlers and rendering logic
}

詳細情報

詳細については、これらのドキュメントページとブログ投稿を参照してください