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

モダンなReduxへの移行

非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

学習内容
  • 手書きのレガシーなReduxロジックをRedux Toolkitで現代化する方法
  • レガシーなReact-Redux connectコンポーネントをフックスAPIで現代化する方法
  • TypeScriptを使用するReduxロジックとReact-Reduxコンポーネントの現代化

概要

Reduxは2015年から存在し、Reduxコードの推奨パターンは年月とともに大きく変化してきました。ReactがcreateClassからReact.Component、そしてフックス付き関数コンポーネントへと進化したのと同じように、Reduxも手動ストア設定+オブジェクトスプレッドの手書きリデューサー+React-Reduxのconnectから、Redux ToolkitのconfigureStorecreateSlice+React-ReduxのフックスAPIへと進化してきました。

多くのユーザーは、こうした「モダンなRedux」パターンが存在する以前からある古いReduxコードベースを扱っています。これらのコードベースを現在推奨されているモダンなReduxパターンに移行すれば、コードベースは大幅に小さくなり、保守も容易になります。

良い知らせは、新旧のReduxコードを共存させながら、コードを段階的に、少しずつモダンなReduxへ移行できることです!

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

情報

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

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

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

  • 既存の手動Reduxストア設定をRedux ToolkitのconfigureStoreに置き換える

  • 既存のスライスリデューサーと関連アクションを選択し、RTKのcreateSliceに置き換える。これをリデューサーごとに繰り返す

  • 必要に応じて既存のデータ取得ロジックをRTK QueryまたはcreateAsyncThunkに置き換える

  • 必要に応じてcreateListenerMiddlewarecreateEntityAdapterなどのRTK APIを使用する

常に最初にレガシーなcreateStore呼び出しをconfigureStoreに置き換えることから始めてください。これは一度だけ行うステップであり、既存のすべてのリデューサーとミドルウェアはそのまま動作し続けます。configureStoreには、誤ったミューテーションや非シリアライズ可能な値といった一般的なミスを検出する開発モードチェックが含まれているため、これらを導入することでコードベース内の問題箇所を特定できます。

情報

この一般的なアプローチの実例は、Redux Fundamentals, Part 8: Modern Redux with Redux Toolkitで確認できます。

configureStoreによるストア設定

典型的なレガシーReduxストア設定ファイルは次の複数のステップを実行します:

  • スライスリデューサーをルートリデューサーに結合する

  • 通常はサンクミドルウェアを含むミドルウェアエンハンサーを作成し、開発モードでは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ステップに集約できます:

Basic Store Setup: 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 Extensionの接続を自動的にセットアップした

追加のミドルウェア導入、thunkミドルウェアへのextra引数渡し、永続化ルートリデューサーの作成など、ストア設定で追加手順が必要な場合も対応可能です。以下は組み込みミドルウェアのカスタマイズとRedux-Persistの有効化を示す大規模な例で、configureStoreの柔軟なオプション活用を実演しています:

Detailed Example: Custom Store Setup with Persistence and Middleware

This example shows several possible common tasks when setting up a Redux store:

  • Combining the reducers separately (sometimes needed due to other architectural constraints)
  • Adding additional middleware, both conditionally and unconditionally
  • Passing an "extra argument" into the thunk middleware, such as an API service layer
  • Using the Redux-Persist library, which requires special handling for its non-serializable action types
  • Turning the devtools off in prod, and setting additional devtools options in development

None of these are required, but they do show up frequently in real-world codebases.

Custom Store Setup: 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({
// Pass previously created persisted reducer
reducer: persistedReducer,
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('Buy milk'))呼び出し時、todoAddedアクションクリエイターに渡す単一値は自動的にaction.payloadフィールドとして使用されます。複数値を渡す必要がある場合、dispatch(todoAdded({id, text}))のようにオブジェクト形式で渡します。別途、createSliceリデューサー内の"prepare"記法を用いて複数引数を受け入れ、payloadフィールドを生成することも可能です。prepare記法は、アイテムごとに一意のID生成など追加処理を行うアクションクリエイターにも有用です。

Redux Toolkitはフォルダ/ファイル構造やアクション命名を強制しませんが、保守性と可読性が高いと確認されているベストプラクティスを推奨しています。

RTK Queryによるデータ取得

React+Reduxアプリにおける従来のデータ取得では、多くの可動部品とコードタイプが必要でした:

  • 「リクエスト開始」「成功」「失敗」を表すアクションクリエイターとアクションタイプ

  • アクションをディスパッチし非同期リクエストを行うThunk

  • ローディング状態を追跡しキャッシュデータを保存するリデューサー

  • ストアから値を読み取るセレクター

  • クラスコンポーネントのcomponentDidMountまたは関数コンポーネントのuseEffectでマウント後にサンクをディスパッチする

これらのコードは通常、複数のファイルに分散しています:

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を使用してデータフェッチを管理している可能性があり、その場合サガをトリガーするための追加の「シグナル」アクションタイプと、サンクの代わりにこのようなサガファイルが存在します:

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「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-sagaredux-observableライブラリを使用して実装されることが一般的です。

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

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、小さなバンドルサイズ、優れたTypeScriptサポートでサガやオブザーバブルを置き換えるように設計されています。

サガとエピックの例はリスナーミドルウェアで次のように置き換えられます:

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はTypeScriptの使用を劇的に簡素化するよう設計されており、可能な限り型を_推論する_ことを推奨しています!

標準的なTypeScriptセットアップガイドに従い、まずストアファイルでAppDispatchRootState型をストア自体から直接推論するように設定します。これにより、ミドルウェアによって追加されたサンクのディスパッチ機能などの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

各スライスファイルは自身のスライス状態の型を宣言・エクスポートする必要があります。次に、createSlice.reducers内のaction引数の型を宣言するためにPayloadAction型を使用します。これにより生成されるアクションクリエイターは、受け入れる引数と返す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クラスコンポーネントを関数コンポーネントに移行する

  • connectラッパーをコンポーネント内部でのuseSelectoruseDispatchフックの使用に置き換える

これはコンポーネント単位で個別に行えます。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
}

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

コンポーネントのTypeScript移行

connectの主な欠点の1つは、型付けが非常に困難で型宣言が極めて冗長になることです。これは高階コンポーネント(HOC)であることと、APIの柔軟性(4つの引数、すべてオプション、各引数に複数のオーバーロードやバリエーション)によるものです。

コミュニティでは様々な複雑さのレベルでこれに対処する複数のパターンが考案されました。単純な方法では、mapState()内でstateの型付けを行い、コンポーネントの全プロパティの型を計算する方法があります:

Simple connect TS example
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)

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

他のコミュニティ作成パターンでは、mapDispatchを関数として宣言し、dispatch: Dispatch<RootActions>型を渡すためにbindActionCreatorsを呼び出す、あるいはラップされたコンポーネントが受け取る全プロパティの型を手動で計算してconnectにジェネリックとして渡すなど、かなりのオーバーヘッドが必要でした。

v7.xで@types/react-reduxに追加されたConnectedProps<T>型はやや改善された代替案で、connectからコンポーネントに渡される全プロパティの型を推論できます。ただし推論を正しく機能させるためにはconnectの呼び出しを2つに分割する必要がありました:

ConnectedProps<T> TS example
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 { AppDispatch, RootState } 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
}

参考情報

詳細については、以下のドキュメントページとブログ記事を参照してください: