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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?