- 技術 💻
Redux Toolkitの使い方|createSliceと非同期処理
Redux Toolkit(RTK)とは、Reduxの公式チームが提供する「Reduxを短く安全に書くための標準ツール一式」です。 createSliceで状態と更新処理をまとめて定義し、configureStoreでストアを作り、コンポーネントからはuseSelectorで読み、useDispatchで更新します。通信などの非同期処理はcreateAsyncThunkで書けます。
この記事では、Redux Toolkit 2.13とReact 19.3の組み合わせで、TypeScriptの完成形コードをすべて実際に動かし、その出力をそのまま掲載しています。型付きフックは最初から withTypes で定義し、createAsyncThunkが流すアクションのログ、よく出会う3つのエラー文と直し方、APIの使い分け表まで順に見ていきます。
確認環境:Ubuntu 24.04/Node.js 22.22.2/@reduxjs/toolkit 2.13.0/react-redux 9.3.0/react・react-dom 19.3.0/TypeScript 7.0.2(スクリプトは tsx 4.23.15 で実行、画面の確認は jsdom 30.1.1)。
Redux Toolkitの早見表
まず、主要なAPIと「いつ使うか」を1枚にまとめます。
| API | 役割 | 使う場面 | import元 |
|---|---|---|---|
createSlice |
状態の初期値・更新処理(reducer)・アクションをまとめて作る | 画面をまたいで共有する状態を1つ定義するとき | @reduxjs/toolkit |
configureStore |
sliceのreducerを束ねてストアを作る | アプリに1つだけ用意する | @reduxjs/toolkit |
Provider |
ストアをコンポーネントツリーに渡す | アプリの最上位で1回だけ囲む | react-redux |
useSelector |
ストアから値を読む | コンポーネントで状態を表示するとき | react-redux |
useDispatch |
アクションを送る関数を得る | ボタン操作などで状態を更新するとき | react-redux |
withTypes |
上の2つのフックに型を結び付ける | TypeScriptで最初に1回だけ定義する | react-redux |
createAsyncThunk |
非同期処理を pending・fulfilled・rejected の3アクションに変える | 通信の開始・成功・エラーを状態に反映したいとき | @reduxjs/toolkit |
createSelector |
計算結果を覚えておくセレクターを作る | 絞り込みや集計など、新しい配列・オブジェクトを返すとき | @reduxjs/toolkit |
RTK Query(createApi) |
取得・キャッシュ・読み込み状態をまとめて面倒を見る | サーバーのデータを取得して表示するのが主目的のとき | @reduxjs/toolkit/query/react |
データの流れは次の1本道です。
flowchart LR
A[コンポーネント] -->|dispatch でアクションを送る| B[ストア]
B -->|reducer が新しい state を作る| C[state]
C -->|useSelector で読む| A
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Redux Toolkitの使い方|createSliceと非同期処理」
インストールとファイル構成
React のプロジェクト(Vite で作ったものなど)に、パッケージを2つ追加します。
npm install @reduxjs/toolkit react-redux
今回入った版は @reduxjs/toolkit 2.13.0 と react-redux 9.3.0 でした。Redux 本体(redux 5.0.1)、Immer、Reselect は @reduxjs/toolkit と一緒に入るので、個別に入れる必要はありません。型定義も同梱されています。
この記事で作るファイルは次のとおりです。
| ファイル | 中身 |
|---|---|
src/counterSlice.ts |
カウンターの状態(同期処理の例) |
src/api.ts |
サーバーの代わりになる関数 |
src/tasksSlice.ts |
タスク一覧の状態(非同期処理の例) |
src/store.ts |
ストアと、そこから取り出す型 |
src/hooks.ts |
型付きフック |
src/main.tsx |
Provider で囲む場所 |
src/App.tsx |
画面 |
createSliceで状態と更新処理を作る
createSlice は「name・initialState・reducers」の3つを渡すと、reducer とアクションを作る関数(アクションクリエーター)を同時に用意してくれます。
// src/counterSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
type CounterState = { value: number }
const initialState: CounterState = { value: 0 }
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value += 1 // Immer が新しい state を作るので、直接書き換える形で書ける
},
decrement(state) {
state.value -= 1
},
addBy(state, action: PayloadAction<number>) {
state.value += action.payload
},
},
})
export const { increment, decrement, addBy } = counterSlice.actions
export default counterSlice.reducer
ポイントは3つです。
- name がアクション名の前半になります。
name: 'counter'とincrementからcounter/incrementという type が自動で作られます。 - reducer の中では state を直接書き換える形で書けます。内部で Immer が変更を記録し、元の state には触れずに新しい state を作ります。
- 引数を受け取る reducer は
PayloadActionで型を付けます。addBy(5)の 5 がaction.payloadに入ります。
configureStoreでストアを作る
// src/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
import tasksReducer from './tasksSlice'
export const store = configureStore({
reducer: {
counter: counterReducer,
tasks: tasksReducer,
},
})
// store から型を取り出す
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
reducer に渡したオブジェクトのキーが、そのまま state の形になります(state.counter.value、state.tasks.items)。configureStore は、非同期処理用のミドルウェアや開発時のチェック、Redux DevTools との接続を最初から組み込んでくれます。
React を使わずに、ストア単体で動きを確かめてみます(tasksSlice は後の章で作ります)。
// src/a_counter.ts
import { store } from './store'
import { increment, addBy } from './counterSlice'
console.log('初期値 :', JSON.stringify(store.getState().counter))
console.log('increment():', JSON.stringify(increment()))
console.log('addBy(5) :', JSON.stringify(addBy(5)))
store.dispatch(increment())
store.dispatch(addBy(5))
console.log('dispatch後:', JSON.stringify(store.getState().counter))
npx tsx src/a_counter.ts の出力です。
初期値 : {"value":0}
increment(): {"type":"counter/increment"}
addBy(5) : {"type":"counter/addBy","payload":5}
dispatch後: {"value":6}
アクションの正体は「type と payload を持つただのオブジェクト」だとわかります。これをストアに dispatch すると、対応する reducer が動いて state が変わります。
型付きフックをwithTypesで定義する
TypeScript では、useSelector と useDispatch に毎回型を書く代わりに、型を結び付けたフックを1回だけ定義して、アプリ全体でそれを使います。react-redux 9 系では withTypes で書けます。Redux Toolkit 公式の TypeScript ガイドでもこの書き方が案内されています。
// src/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
// アプリ全体で、素の useDispatch / useSelector の代わりにこの2つを使う
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
以前からある TypedUseSelectorHook を使う書き方と比べると、次のようになります。どちらも同じ型が付きますが、withTypes は2行とも同じ形でそろい、型の import も減ります。
| 項目 | withTypes | TypedUseSelectorHook を使う書き方 |
|---|---|---|
| useSelector | useSelector.withTypes<RootState>() |
const useAppSelector: TypedUseSelectorHook<RootState> = useSelector |
| useDispatch | useDispatch.withTypes<AppDispatch>() |
const useAppDispatch: () => AppDispatch = useDispatch |
| 書き方の統一感 | 2つとも同じ形 | 2つで形が異なる |
型付きフックで見つかる間違い
型付きフックの効果を、わざと間違えたコードで確かめます。
// src/g_types.tsx
import { useDispatch } from 'react-redux'
import { useAppSelector } from './hooks'
import { addBy } from './counterSlice'
import { fetchTasks } from './tasksSlice'
export function Bad() {
const value = useAppSelector((state) => state.counter.valu) // (1) プロパティ名の打ち間違い
const dispatch = useDispatch() // (2) 型の付いていない素の useDispatch
dispatch(fetchTasks(false))
dispatch(addBy('5')) // (3) number のはずが string
return <p>{value}</p>
}
npx tsc --noEmit の出力です。
src/g_types.tsx(7,57): error TS2551: Property 'valu' does not exist on type 'CounterState'. Did you mean 'value'?
src/g_types.tsx(9,12): error TS2345: Argument of type 'AsyncThunkAction<Task[], boolean | undefined, AsyncThunkConfig>' is not assignable to parameter of type 'UnknownAction'.
src/g_types.tsx(10,18): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
| 番号 | エラーの意味 | 直し方 |
|---|---|---|
| (1) TS2551 | state の形を知っているので、打ち間違いに候補つきで気付ける | value に直す |
| (2) TS2345 | 素の useDispatch は「非同期処理(thunk)を渡せる」ことを知らない | useAppDispatch() に替える |
| (3) TS2345 | PayloadAction<number> に文字列を渡している |
addBy(5) に直す |
(2) は createAsyncThunk を使い始めたときに最初に出会いやすいエラーです。useAppDispatch に替えるだけで解消します。
Providerで囲んでコンポーネントから使う
ストアは、アプリの最上位で Provider に渡します。
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import { App } from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)
コンポーネントでは、型付きフックで読み書きします。
// src/App.tsx
import { useAppDispatch, useAppSelector } from './hooks'
import { increment, addBy } from './counterSlice'
import { fetchTasks } from './tasksSlice'
export function App() {
const count = useAppSelector((state) => state.counter.value)
const { items, status, error } = useAppSelector((state) => state.tasks)
const dispatch = useAppDispatch()
return (
<main>
<p>カウント: {count}</p>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(addBy(5))}>+5</button>
<button onClick={() => dispatch(fetchTasks(false))}>タスクを読み込む</button>
{status === 'loading' && <p>読み込み中…</p>}
{status === 'failed' && <p role="alert">{error}</p>}
<ul>
{items.map((t) => (
<li key={t.id}>
{t.title}
{t.done ? '(完了)' : ''}
</li>
))}
</ul>
</main>
)
}
useAppSelector に渡す関数(セレクター)は「state 全体から必要な部分だけを取り出す関数」です。 取り出した値が前回と変わったときだけ、そのコンポーネントが再描画されます。
この画面を jsdom の中で描画し、ボタンを順にクリックして表示を取り出した結果です(次章の tasksSlice を含めて動かしています)。
初期表示 : カウント: 0
+1 と +5 の後 : カウント: 6
読み込み直後 : カウント: 6 / 読み込み中…
400ms 後 : 備品を発注する / 議事録を共有する(完了)
createAsyncThunkで非同期処理を書く
createAsyncThunk は、Promise を返す関数を渡すと、開始・成功・エラーの3つのアクションを自動で dispatch してくれる関数を作ります。
まず、サーバーの代わりになる関数を用意します。実際のアプリでは、ここが fetch などの通信になります。
// src/api.ts
export type Task = { id: number; title: string; done: boolean }
// サーバーの代わりになる関数。300ms 後に結果を返す
export function fetchTasksApi(fail = false): Promise<Task[]> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (fail) reject(new Error('サーバーに接続できませんでした'))
else
resolve([
{ id: 1, title: '備品を発注する', done: false },
{ id: 2, title: '議事録を共有する', done: true },
])
}, 300)
})
}
slice では、createAsyncThunk が作る3つのアクションを extraReducers の builder.addCase で受け取ります。
// src/tasksSlice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { fetchTasksApi, type Task } from './api'
type TasksState = {
items: Task[]
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
const initialState: TasksState = { items: [], status: 'idle', error: null }
// 第1引数はアクション名の前半。tasks/fetchTasks/pending などが自動で作られる
export const fetchTasks = createAsyncThunk(
'tasks/fetchTasks',
async (fail: boolean | undefined) => {
return await fetchTasksApi(fail)
},
)
export const tasksSlice = createSlice({
name: 'tasks',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTasks.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchTasks.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload // Task[] と推論される
})
.addCase(fetchTasks.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? '読み込めませんでした'
})
},
})
export default tasksSlice.reducer
reducers と extraReducers の違いは次のとおりです。
| 項目 | reducers | extraReducers |
|---|---|---|
| 対象 | この slice 自身のアクション | slice の外で作られたアクション(createAsyncThunk、他の slice など) |
| アクションクリエーター | 自動で作られる | 作られない(既にあるものに反応するだけ) |
| 書き方 | メソッドを並べる | (builder) => { builder.addCase(...) } |
pending・fulfilled・rejectedで流れるアクションをログで見る
dispatch されたアクションをすべて表示するミドルウェアを挟んで、成功する場合とエラーになる場合を実行します。
// src/b_async.ts
import { configureStore, type Middleware } from '@reduxjs/toolkit'
import tasksReducer, { fetchTasks } from './tasksSlice'
// dispatch されたアクションをすべて表示するミドルウェア
const logger: Middleware = () => (next) => (action) => {
if (typeof action === 'object' && action !== null && 'type' in action) {
const a = action as { type: string; payload?: unknown; error?: { message?: string }; meta?: { requestStatus?: string } }
console.log('[action]', a.type)
if (a.payload !== undefined) console.log(' payload:', JSON.stringify(a.payload))
if (a.error) console.log(' error.message:', a.error.message)
console.log(' meta.requestStatus:', a.meta?.requestStatus)
}
return next(action)
}
const store = configureStore({
reducer: { tasks: tasksReducer },
middleware: (getDefault) => getDefault().concat(logger),
})
console.log('--- 成功する場合 ---')
await store.dispatch(fetchTasks(false))
console.log('state:', JSON.stringify(store.getState().tasks))
console.log('--- エラーになる場合 ---')
const result = await store.dispatch(fetchTasks(true))
console.log('state:', JSON.stringify(store.getState().tasks))
console.log('fetchTasks.rejected.match(result):', fetchTasks.rejected.match(result))
console.log('--- unwrap で try/catch に乗せる ---')
try {
await store.dispatch(fetchTasks(true)).unwrap()
} catch (e) {
console.log('catch:', (e as { message: string }).message)
}
npx tsx src/b_async.ts の出力です。
--- 成功する場合 ---
[action] tasks/fetchTasks/pending
meta.requestStatus: pending
[action] tasks/fetchTasks/fulfilled
payload: [{"id":1,"title":"備品を発注する","done":false},{"id":2,"title":"議事録を共有する","done":true}]
meta.requestStatus: fulfilled
state: {"items":[{"id":1,"title":"備品を発注する","done":false},{"id":2,"title":"議事録を共有する","done":true}],"status":"succeeded","error":null}
--- エラーになる場合 ---
[action] tasks/fetchTasks/pending
meta.requestStatus: pending
[action] tasks/fetchTasks/rejected
error.message: サーバーに接続できませんでした
meta.requestStatus: rejected
state: {"items":[{"id":1,"title":"備品を発注する","done":false},{"id":2,"title":"議事録を共有する","done":true}],"status":"failed","error":"サーバーに接続できませんでした"}
fetchTasks.rejected.match(result): true
--- unwrap で try/catch に乗せる ---
[action] tasks/fetchTasks/pending
meta.requestStatus: pending
[action] tasks/fetchTasks/rejected
error.message: サーバーに接続できませんでした
meta.requestStatus: rejected
catch: サーバーに接続できませんでした
この出力から読み取れることを表にまとめます。
| タイミング | dispatch されるアクション | 中身 | reducer で行うこと |
|---|---|---|---|
| 呼び出した直後 | tasks/fetchTasks/pending |
payload なし | 読み込み中の表示に切り替える |
| Promise が成功 | tasks/fetchTasks/fulfilled |
payload に戻り値 |
データを state に入れる |
| Promise がエラー | tasks/fetchTasks/rejected |
error.message にメッセージ |
エラー表示用の文言を state に入れる |
大事な点は、中の処理が例外を投げても store.dispatch(...) 自体は例外を投げず、rejected のアクションを結果として返すことです。呼び出し側で成功・エラーを分けたいときは、次の2つの方法があります。
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
| match で判定 | fetchTasks.rejected.match(result) |
if 文で分岐したいとき |
| unwrap | await dispatch(fetchTasks()).unwrap() |
try/catch で書きたいとき(成功時は payload がそのまま返る) |
なお、エラーになったあとも items には前回成功時のデータが残っています。エラー時に一覧を空にしたい場合は、rejected の addCase で state.items = [] を足します。
rejectWithValueでエラーの中身を自分で決める
rejected アクションの error には、例外の message などが文字列として入ります。エラーコードのような独自の情報を渡したいときは rejectWithValue を使い、型は3つ目の型引数の rejectValue で指定します。
// src/j_reject.ts
import { configureStore, createAsyncThunk, createSlice } from '@reduxjs/toolkit'
type ApiError = { code: string; message: string }
export const saveTitle = createAsyncThunk<
{ title: string }, // 成功時の payload
string, // 引数
{ rejectValue: ApiError } // rejectWithValue に渡す値の型
>('title/save', async (title, { rejectWithValue }) => {
if (title.trim() === '') {
return rejectWithValue({ code: 'EMPTY', message: 'タイトルを入力してください' })
}
return { title }
})
const slice = createSlice({
name: 'title',
initialState: { title: '', error: null as ApiError | null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(saveTitle.fulfilled, (state, action) => {
state.title = action.payload.title
state.error = null
})
.addCase(saveTitle.rejected, (state, action) => {
// rejectWithValue を通った場合だけ payload に値が入る
state.error = action.payload ?? { code: 'UNKNOWN', message: action.error.message ?? '' }
})
},
})
const store = configureStore({ reducer: { title: slice.reducer } })
const result = await store.dispatch(saveTitle(' '))
console.log('type :', result.type)
console.log('payload:', JSON.stringify(result.payload))
console.log('error :', JSON.stringify((result as { error?: unknown }).error))
console.log('state :', JSON.stringify(store.getState().title))
type : title/save/rejected
payload: {"code":"EMPTY","message":"タイトルを入力してください"}
error : {"message":"Rejected"}
state : {"title":"","error":{"code":"EMPTY","message":"タイトルを入力してください"}}
rejectWithValue を使うと、rejected アクションの payload に渡した値が入り、error.message は固定の「Rejected」になります。 画面に出す文言は payload から取り出します。
よく出会うエラー3つと直し方
ここからは、Redux Toolkit を使い始めたときに出会いやすいエラーを実際に出して、原因と直し方を確認します。
| エラー文(先頭) | 原因 | 直し方 |
|---|---|---|
| The object notation for `createSlice.extraReducers` has been removed. | extraReducers をオブジェクトで書いている(RTK 1 系の書き方) | builder を受け取る関数に書き換える |
| A non-serializable value was detected in an action | Date やクラスのインスタンスなどを action や state に入れている | 文字列や数値に変換して持つ |
| could not find react-redux context value | Provider で囲まれていない場所でフックを使っている | 最上位を Provider で囲む |
extraReducersのオブジェクト記法はRTK 2で使えない
RTK 1 系の記事やコードには、extraReducers をオブジェクトで書いた例があります。RTK 2 で同じ書き方をするとどうなるか、試します。
// src/c_object.ts
import { createSlice, createAction } from '@reduxjs/toolkit'
const reset = createAction('reset')
try {
const slice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {},
// RTK 1 系で書けたオブジェクト記法
extraReducers: {
[reset.type]: (state: { value: number }) => {
state.value = 0
},
} as any,
})
slice.reducer(undefined, { type: 'init' })
} catch (e) {
console.log((e as Error).message)
}
The object notation for `createSlice.extraReducers` has been removed. Please use the 'builder callback' notation instead: https://redux-toolkit.js.org/api/createSlice
TypeScript では型の段階でも受け付けないため、上の例では確認のために as any を付けています。builder の関数に直した完成形は次のとおりです。
// src/c2_builder.ts
import { configureStore, createAction, createSlice } from '@reduxjs/toolkit'
const reset = createAction('reset')
const slice = createSlice({
name: 'counter',
initialState: { value: 5 },
reducers: {},
// builder を受け取る関数で書く
extraReducers: (builder) => {
builder.addCase(reset, (state) => {
state.value = 0
})
},
})
const store = configureStore({ reducer: { counter: slice.reducer } })
store.dispatch(reset())
console.log(JSON.stringify(store.getState()))
{"counter":{"value":0}}
公式の移行ガイド(Migrating to RTK 2.0)には、createReducer のオブジェクト記法も同じく削除されたこと、configureStore の middleware は (getDefaultMiddleware) => getDefaultMiddleware().concat(x) のような関数の形だけを受け付けること、AnyAction に代えて UnknownAction を使うことも書かれています。
A non-serializable value was detected が出るとき
Redux では、action と state には「JSON にそのまま変換できる値(シリアライズ可能な値)」だけを入れるのが決まりです。Date を入れてみます。
// src/d_serial.ts
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit'
const slice = createSlice({
name: 'memo',
initialState: { savedAt: null as Date | null },
reducers: {
saved(state, action: PayloadAction<Date>) {
state.savedAt = action.payload
},
},
})
const store = configureStore({ reducer: { memo: slice.reducer } })
store.dispatch(slice.actions.saved(new Date('2026-10-03T09:00:00Z')))
A non-serializable value was detected in an action, in the path: `payload`. Value: 2026-10-03T09:00:00.000Z
Take a look at the logic that dispatched this action: { type: 'memo/saved', payload: 2026-10-03T09:00:00.000Z }
(See https://redux.js.org/faq/actions#why-should-type-be-a-string-why-should-my-action-types-be-constants)
(To allow non-serializable values see: https://redux-toolkit.js.org/usage/usage-guide#working-with-non-serializable-data)
A non-serializable value was detected in the state, in the path: `memo.savedAt`. Value: 2026-10-03T09:00:00.000Z
Take a look at the reducer(s) handling this action type: memo/saved.
(See https://redux.js.org/faq/organizing-state#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state)
action と state の2か所について、どのパス(payload、memo.savedAt)にどんな値が入っていたかまで教えてくれます。処理は止まらず、コンソールへの出力だけです。同じスクリプトを NODE_ENV=production を付けて実行したところ、この出力は出ませんでした。開発中に気付くための仕組みと考えるとよいでしょう。
基本の直し方は、文字列で持ち、表示するときに Date へ戻すことです。
// src/d2_serial.ts
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit'
const slice = createSlice({
name: 'memo',
initialState: { savedAt: null as string | null },
reducers: {
// Date ではなく ISO 形式の文字列で持つ
saved(state, action: PayloadAction<string>) {
state.savedAt = action.payload
},
},
})
const store = configureStore({ reducer: { memo: slice.reducer } })
store.dispatch(slice.actions.saved(new Date('2026-10-03T09:00:00Z').toISOString()))
console.log(JSON.stringify(store.getState()))
// 表示するときに Date へ戻す
console.log(new Date(store.getState().memo.savedAt!).getFullYear())
{"memo":{"savedAt":"2026-10-03T09:00:00.000Z"}}
2026
入れがちな値と、置き換え先の目安です。
| 入れがちな値 | 置き換え先 |
|---|---|
| Date | ISO 形式の文字列、または数値(ミリ秒) |
| Map・Set | 普通のオブジェクト・配列 |
| クラスのインスタンス | 必要な項目だけを持つ普通のオブジェクト |
| 関数・Promise | state には入れず、thunk やコンポーネント側で扱う |
どうしても特定の場所だけチェックから外したい場合は、configureStore で指定できます。
// src/k_ignore.ts
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit'
const slice = createSlice({
name: 'memo',
initialState: { savedAt: null as Date | null },
reducers: {
saved(state, action: PayloadAction<Date>) {
state.savedAt = action.payload
},
},
})
const store = configureStore({
reducer: { memo: slice.reducer },
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['memo/saved'], // このアクションは調べない
ignoredPaths: ['memo.savedAt'], // state のこの場所は調べない
},
}),
})
store.dispatch(slice.actions.saved(new Date('2026-10-03T09:00:00Z')))
console.log('警告なしで完了:', store.getState().memo.savedAt instanceof Date)
警告なしで完了: true
公式ドキュメント(Serializability Middleware)によると、ほかに ignoredActionPaths(既定値は meta.arg と meta.baseQueryMeta)なども指定できます。createAsyncThunk に渡した引数(meta.arg に入ります)が最初からチェック対象外なのは、この既定値によるものです。
Providerで囲んでいないときのエラー
main.tsx の Provider を外して App だけを描画すると、次のエラーで止まります。
could not find react-redux context value; please ensure the component is wrapped in a <Provider>
useSelector や useDispatch は、Provider が配ったストアを React の Context 経由で受け取ります。 次の点を順に確かめると解決できます。
| 確認する点 | 内容 |
|---|---|
| main.tsx | App が Provider の内側にあるか、store を渡しているか |
| フックを呼ぶ場所 | Provider を書いたコンポーネント自身ではなく、その子で呼んでいるか |
| テスト | テスト用の描画でも Provider で囲んでいるか |
| 別のルート | モーダルなどを別の createRoot で描画している場合、そちらも囲んでいるか |
createSelectorで計算結果を覚えておく
useSelector は、セレクターの戻り値が前回と別の参照になると再描画します。filter や map は呼ぶたびに新しい配列を作るため、state が変わっていなくても「変わった」と判定されます。 createSelector は、入力が同じなら前回の結果をそのまま返します。
// src/h_selector.ts
import { createSelector } from '@reduxjs/toolkit'
import type { Task } from './api'
type State = { tasks: { items: Task[] }; counter: { value: number } }
let calls = 0
const selectItems = (state: State) => state.tasks.items
export const selectOpenTasks = createSelector([selectItems], (items) => {
calls++
return items.filter((t) => !t.done)
})
const items: Task[] = [
{ id: 1, title: '備品を発注する', done: false },
{ id: 2, title: '議事録を共有する', done: true },
]
const s1: State = { tasks: { items }, counter: { value: 0 } }
const s2: State = { ...s1, counter: { value: 1 } } // tasks は同じ参照のまま
const r1 = selectOpenTasks(s1)
const r2 = selectOpenTasks(s2)
console.log('計算回数:', calls)
console.log('同じ配列を返したか:', r1 === r2)
// メモ化しない書き方だと毎回あたらしい配列になる
const plain = (state: State) => state.tasks.items.filter((t) => !t.done)
console.log('filter を直接書いた場合:', plain(s1) === plain(s2))
計算回数: 1
同じ配列を返したか: true
filter を直接書いた場合: false
counter だけが変わった s2 に対して、createSelector は再計算せず同じ配列を返しています。使い分けの目安は次のとおりです。
| セレクターが返すもの | 書き方 |
|---|---|
| state にある値そのまま(数値、文字列、配列そのもの) | 普通の関数で十分 |
| filter・map・集計などで新しく作った配列やオブジェクト | createSelector |
RTK Queryでデータ取得をまとめる
RTK Query は Redux Toolkit に同梱されている、データ取得とキャッシュのための仕組みです。 公式ドキュメントでは「データ取得とキャッシュのロジックを手で書かずに済むようにするツール」と説明されています。createAsyncThunk で書いた「status と error を自分で持つ」部分を、まるごと任せられます。
React で使うときは @reduxjs/toolkit/query/react から import します。こちらを使うと、エンドポイントごとのフックが自動で作られます。
// src/tasksApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Task } from './api'
export const tasksApi = createApi({
reducerPath: 'tasksApi',
baseQuery: fetchBaseQuery({ baseUrl: 'http://127.0.0.1:8870' }),
endpoints: (build) => ({
getTasks: build.query<Task[], void>({ query: () => '/tasks' }),
}),
})
// エンドポイント名から use〜Query というフックが自動で作られる
export const { useGetTasksQuery } = tasksApi
ストアには、reducer とミドルウェアの2つを登録します。
// src/apiStore.ts
import { configureStore } from '@reduxjs/toolkit'
import { tasksApi } from './tasksApi'
export const apiStore = configureStore({
reducer: { [tasksApi.reducerPath]: tasksApi.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(tasksApi.middleware),
})
コンポーネントはフックを呼ぶだけです。
// src/TaskList.tsx
import { useGetTasksQuery } from './tasksApi'
export function TaskList() {
const { data, isLoading, isError } = useGetTasksQuery()
if (isLoading) return <p>読み込み中…</p>
if (isError) return <p role="alert">読み込めませんでした</p>
return (
<ul>
{data?.map((t) => <li key={t.id}>{t.title}</li>)}
</ul>
)
}
/tasks にタスク1件の JSON を返す確認用サーバーをポート 8870 で立て、apiStore を渡した Provider で囲んで jsdom に描画した結果です。
描画直後: <p>読み込み中…</p>
300ms 後: <ul><li>備品を発注する</li></ul>
slice も status も書かずに、読み込み中の表示とデータの表示が切り替わりました。
RTK Queryの内部で流れるアクション
フックを使わず tasksApi.endpoints.getTasks.initiate() を直接 dispatch し、先ほどのログ用ミドルウェアで確認しました。1回目の取得、同じ取得の2回目、存在しない URL(404)の取得を順に行っています。
[action] tasksApi/config/middlewareRegistered
[action] tasksApi/executeQuery/pending
[action] tasksApi/executeQuery/fulfilled
status: fulfilled / data: [{"id":1,"title":"備品を発注する","done":false}]
2回目 status: fulfilled (キャッシュがあるので通信のアクションは出ない)
[action] tasksApi/executeQuery/pending
[action] tasksApi/executeQuery/rejected
status: rejected / error: {"status":404,"data":{"message":"Not Found"}}
RTK Query の中でも pending・fulfilled・rejected という同じ流れが使われています。 2回目の取得では executeQuery のアクションが出ておらず、キャッシュから結果が返っています。404 のときは、error に HTTP ステータスとレスポンス本文が入ります。
公式ドキュメントでは、ストア作成後に setupListeners(store.dispatch) を呼ぶと、再接続時などの再取得(refetchOnReconnect など)が使えるようになると案内されています。
createAsyncThunkとRTK Queryの使い分け
| やりたいこと | 向いている方法 | 理由 |
|---|---|---|
| サーバーのデータを取得して表示する | RTK Query | キャッシュ・読み込み状態・フックが自動で用意される |
| 同じデータを複数の画面で使う | RTK Query | 同じ取得は1回にまとめられる |
| 通信以外の非同期処理(タイマー、ファイル読み込みなど) | createAsyncThunk | Promise を返す処理なら何でも書ける |
| 成功時に複数の slice を細かく更新する | createAsyncThunk | extraReducers で各 slice が自由に反応できる |
| 画面の開閉、選択中のタブ、入力途中の値 | createSlice(同期) | 通信が関係しない |
よくある質問
Q. Redux と Redux Toolkit は別のものですか? A. Redux Toolkit は Redux 本体を内部で使う公式のツール一式です。今回の環境でも、@reduxjs/toolkit を入れると redux 5.0.1 が一緒に入りました。公式の移行ガイドでは、Redux 本体の createStore は非推奨の印が付き、configureStore の利用が案内されています。
Q. reducer の中で state を直接書き換えてよいのですか? A. createSlice の reducers と extraReducers の中では問題ありません。Immer が変更を記録して新しい state を作るためです。コンポーネントの中など reducer の外では、useSelector で受け取った値を直接書き換えず、必ずアクションを dispatch してください。
Q. dispatch した非同期処理の結果を、その場で受け取れますか?
A. 受け取れます。await dispatch(fetchTasks()) は fulfilled か rejected のアクションを返します。.unwrap() を付けると、成功時は payload がそのまま返り、エラー時は例外として catch できます。
まとめ
- createSlice で name・initialState・reducers を渡すと、reducer とアクションクリエーターがまとめて作られる
- configureStore の reducer のキーが state の形になり、そこから RootState と AppDispatch の型を取り出す
- 型付きフックは useSelector.withTypes と useDispatch.withTypes で1回だけ定義し、アプリ全体で使う
- createAsyncThunk は pending → fulfilled または rejected のアクションを自動で流し、extraReducers の builder.addCase で受け取る
- extraReducers のオブジェクト記法は RTK 2 で削除されており、builder の関数に書き換える
- A non-serializable value was detected は Date などを文字列に変換して持つと解消する
- サーバーのデータ取得が中心なら RTK Query、それ以外の非同期処理は createAsyncThunk が向いている
参考・出典
- Redux Toolkit 公式「Usage With TypeScript」 https://redux.js.org/toolkit/usage/usage-with-typescript
- Redux 公式「Migrating to RTK 2.0 and Redux 5.0」 https://redux.js.org/usage/migrations/migrating-rtk-2
- Redux Toolkit 公式「Serializability Middleware」 https://redux-toolkit.js.org/api/serializabilityMiddleware
- Redux Toolkit 公式「RTK Query Overview」 https://redux.js.org/toolkit/rtk-query/overview
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Redux Toolkitの使い方|createSliceと非同期処理」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。