「Redux Toolkit 使い方を一から実装レベルで理解したい」「createSliceとcreateAsyncThunkとRTK Query、結局どう使い分けるの?」――2026年のReact開発者がチームに合流して最初にぶつかる壁です。本記事は、useState/useReducer/useContextからのステップアップを終えたエンジニアに向けて、Redux Toolkit(RTK)の本当に使う機能だけを実装コード中心で凝縮しました。store設定 → createSlice → createAsyncThunk → createEntityAdapter → RTK Query → listener middleware → 永続化 → テストまで、コードブロック30本超・コピペで動く完全なTypeScript型注釈付きで解説します。読み終える頃には、認証フロー・楽観的UI更新・無限スクロール・キャッシュ無効化・正規化stateといった「現場で必ず書く」パターンが、自分の手で迷いなく組めるようになっているはずです。状態管理ライブラリ比較記事でRTKを採用すると決めた人の、次の一歩のための実装ガイドです。
- Redux Toolkit導入前に押さえる前提知識
- 環境セットアップとStore初期設定
- createSlice: RTKの心臓部を完全理解
- createAsyncThunk: 非同期処理の標準パターン
- createEntityAdapter: 正規化stateの標準
- 派生stateとメモ化selectorの設計
- RTK Query: サーバー状態管理の決定版
- middleware: listenerMiddlewareで副作用を扱う
- 永続化・テスト・デバッグの実践
- RTK実装でやりがちなアンチパターンと対策
- RTK主要API早見表
- 収益化を目指すなら学習効率を最優先に
- よくある質問(FAQ)
- まとめ: 実装力で差をつけるRTK
Redux Toolkit導入前に押さえる前提知識
Redux Toolkit(RTK)は、Reduxチームが公式に提供する「現代版Redux」です。かつての「actionType定数を切り、actionCreatorを書き、reducerでswitchを書き、combineReducersで束ねる」というボイラープレート地獄を、createSliceとconfigureStoreの2つで一気に解消しました。Immerが内部に組み込まれているため「見た目はmutableに書けるが内部はimmutable」という、最も安全で書きやすい更新スタイルが手に入ります。useContext完全ガイドやuseReducer完全ガイドを読み終えた段階で「Contextの再レンダリング問題」「reducerのactionTypeが膨らんできた」と感じたら、本記事のRTKに移行するタイミングです。
RTKが解決する3つの課題
素のReduxを書いていた経験者にとってRTKの価値は明確ですが、初学者には「何が嬉しいのか」が見えづらい部分があります。RTKが解決するのは具体的に次の3点です。
- 三重定義問題の解消: actionType定数・actionCreator関数・reducerのswitch caseという3カ所への書き分けが、createSlice一発で消滅します。
- 不変性のスプレッド地獄解消: ネストしたstateを更新するための
{...state, nested: {...state.nested, x: y}}連発が、Immerのおかげでstate.nested.x = yと書けるようになります。 - 非同期・middleware設定の自動化: createAsyncThunkでpending/fulfilled/rejectedが自動生成され、configureStoreはredux-thunk・Redux DevTools・serializableCheckをデフォルト有効化します。
useReducer/Context構成からの移行判断
「Contextでreducerを配信する」構成で十分な規模なら、RTKは不要です。useReducer完全ガイドで紹介した構成のままで戦えます。一方、5つ以上のドメイン状態(認証・カート・通知・モーダル・サーバーキャッシュ等)が絡み始めたら、RTKに移行することでDevTools・型安全性・middlewareの恩恵が一気に効いてきます。状態管理ライブラリ比較で「中〜大規模・長期保守・型安全重視」と判定されたプロジェクトが、RTKの本命採用ゾーンです。
環境セットアップとStore初期設定
まずは動く最小構成から始めます。Vite + React + TypeScriptを前提に、必要なパッケージをすべて含めたインストール手順とディレクトリ構成を示します。
package.jsonとインストール手順
// 1. プロジェクト作成(Vite + React + TypeScript)
npm create vite@latest my-app -- --template react-ts
cd my-app
// 2. Redux Toolkit・React-Redux本体
npm install @reduxjs/toolkit react-redux
// 3. 型と開発系
npm install -D @types/react-redux
// 4. 永続化が必要な場合(任意)
npm install redux-persist
// package.json(抜粋・2026年時点の推奨バージョン)
{
"name": "rtk-practice",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest"
},
"dependencies": {
"@reduxjs/toolkit": "^2.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-redux": "^7.1.34",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
}
推奨ディレクトリ構成(feature-folder方式)
Reduxチームが公式に推奨しているのがfeature folder構成です。技術的関心(actions/reducers/selectorsで分割)ではなく、ドメイン(auth/cart/posts等で分割)でフォルダを切ります。この構成のメリットは以下の通りです。
- 機能単位で消しやすい: 機能を削除する時に該当フォルダごと削除でき、ファイル探索コストがゼロになる。
- 新規参加者の認知負荷が低い: 「カート機能はfeatures/cart/を見ればいい」と一目でわかる。
- テストの所在が一致する: Slice・コンポーネント・テストが同じフォルダに並ぶため、レビュー範囲が局所化する。
- サブツリー単位でのコード分割が容易: dynamic importで機能別に遅延ロードする時もフォルダ単位で完結する。
src/
├── app/
│ ├── store.ts # configureStore本体
│ └── hooks.ts # 型付きuseSelector/useDispatch
├── features/
│ ├── auth/
│ │ ├── authSlice.ts
│ │ └── LoginForm.tsx
│ ├── cart/
│ │ ├── cartSlice.ts
│ │ └── Cart.tsx
│ └── posts/
│ ├── postsSlice.ts
│ └── PostList.tsx
├── services/
│ └── api.ts # RTK Query
└── main.tsx # <Provider>ラップ
store.ts: configureStoreの基本形
// src/app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "../features/auth/authSlice";
import cartReducer from "../features/cart/cartSlice";
import postsReducer from "../features/posts/postsSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
posts: postsReducer,
},
// middlewareはRTKがデフォルトで thunk / serializableCheck / immutableCheck を有効化
devTools: process.env.NODE_ENV !== "production",
});
// 型ヘルパー: アプリ全体で使い回す
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
型付きuseSelector / useDispatchの定義
毎回useSelector<RootState>と書くのは冗長なので、型付きHookを一度定義してプロジェクト全体で再利用するのが公式推奨パターンです。
// src/app/hooks.ts
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// 以降は useAppDispatch / useAppSelector を使えばすべて型推論される
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
ProviderでStoreをアプリにマウント
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./app/store";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
createSlice: RTKの心臓部を完全理解
createSliceは「name・initialState・reducers」の3つを渡すだけで、reducer本体・actionCreator・actionTypeを自動生成してくれます。素のReduxで20行書いていたカウンターが、たったの数行で完成します。
最小例: counterSliceの作成
// src/features/counter/counterSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
state.value += 1; // ← Immerで mutable風に書ける(内部はimmutable)
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
コンポーネントから使う
// src/features/counter/Counter.tsx
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { increment, decrement, incrementByAmount } from "./counterSlice";
export function Counter() {
const value = useAppSelector((s) => s.counter.value);
const dispatch = useAppDispatch();
return (
<div>
<p>Count: {value}</p>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
</div>
);
}
複雑なstate: todosSlice(配列・ネスト)
実務では「IDで検索して特定要素を更新」「フィルター済み一覧を派生」といった処理が必須です。Immerのおかげで、ネスト構造でも直感的な書き方がそのまま使えます。
// src/features/todos/todosSlice.ts
import { createSlice, type PayloadAction, nanoid } from "@reduxjs/toolkit";
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
interface TodosState {
items: Todo[];
filter: "all" | "active" | "completed";
}
const initialState: TodosState = {
items: [],
filter: "all",
};
const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
// prepare callbackでidとcreatedAtを自動付与
addTodo: {
reducer: (state, action: PayloadAction<Todo>) => {
state.items.push(action.payload);
},
prepare: (text: string) => ({
payload: {
id: nanoid(),
text,
completed: false,
createdAt: Date.now(),
} as Todo,
}),
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed; // ← Immerで安全に書ける
},
removeTodo: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
setFilter: (state, action: PayloadAction<TodosState["filter"]>) => {
state.filter = action.payload;
},
clearCompleted: (state) => {
state.items = state.items.filter((t) => !t.completed);
},
},
});
export const { addTodo, toggleTodo, removeTodo, setFilter, clearCompleted } =
todosSlice.actions;
export default todosSlice.reducer;
prepareコールバックでaction payloadを整形
上のコード内に出てきたprepareは、action payloadを生成する側でidやtimestampを付与したい時に使う公式パターンです。コンポーネント側からはdispatch(addTodo("買い物"))と呼ぶだけで、内部でidが自動採番されます。
extraReducersで他Sliceのactionを購読
「ログアウト時にすべてのSliceをリセットしたい」というよくある要件は、extraReducersで他Sliceのactionに反応させて実現します。
// src/features/auth/authSlice.ts(抜粋)
import { createSlice, createAction } from "@reduxjs/toolkit";
export const logout = createAction("auth/logout");
// 例: cartSlice側でログアウトを検知してリセット
import { createSlice as createCartSlice } from "@reduxjs/toolkit";
const cartSlice = createCartSlice({
name: "cart",
initialState: { items: [] },
reducers: { /* ... */ },
extraReducers: (builder) => {
builder.addCase(logout, (state) => {
state.items = []; // ログアウト時にカートを空にする
});
},
});
createAsyncThunk: 非同期処理の標準パターン
APIを叩いてstateに反映する処理は、RTKではcreateAsyncThunkを使うのが標準です。pending / fulfilled / rejectedの3状態が自動生成され、ローディングUIとエラーUIを統一的に書けるようになります。
基本形: ユーザー取得Thunk
// src/features/users/usersSlice.ts
import { createSlice, createAsyncThunk, type PayloadAction } from "@reduxjs/toolkit";
interface User {
id: number;
name: string;
email: string;
}
interface UsersState {
list: User[];
status: "idle" | "loading" | "succeeded" | "failed";
error: string | null;
}
const initialState: UsersState = { list: [], status: "idle", error: null };
// 第1引数: action typeのprefix / 第2引数: 非同期処理本体
export const fetchUsers = createAsyncThunk<User[]>(
"users/fetchUsers",
async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) throw new Error("Failed to fetch");
return (await res.json()) as User[];
}
);
const usersSlice = createSlice({
name: "users",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = "loading";
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
state.status = "succeeded";
state.list = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = "failed";
state.error = action.error.message ?? "Unknown error";
});
},
});
export default usersSlice.reducer;
コンポーネントから呼び出す
// src/features/users/UserList.tsx
import { useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { fetchUsers } from "./usersSlice";
export function UserList() {
const dispatch = useAppDispatch();
const { list, status, error } = useAppSelector((s) => s.users);
useEffect(() => {
if (status === "idle") dispatch(fetchUsers());
}, [status, dispatch]);
if (status === "loading") return <p>Loading...</p>;
if (status === "failed") return <p>Error: {error}</p>;
return (
<ul>
{list.map((u) => (
<li key={u.id}>{u.name} - {u.email}</li>
))}
</ul>
);
}
引数付きThunk + rejectWithValueでエラーハンドリング
実務では「APIが400/500を返した時のサーバーエラー本文をUIに出したい」場面が多くあります。rejectWithValueを使えば、JS例外ではない「業務エラー」を型安全に拾えます。
// src/features/auth/authSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
interface LoginPayload { email: string; password: string; }
interface LoginResponse { token: string; user: { id: number; name: string }; }
interface RejectValue { code: string; message: string; }
export const login = createAsyncThunk<
LoginResponse, // 戻り値
LoginPayload, // 引数
{ rejectValue: RejectValue } // rejectWithValueの型
>("auth/login", async (payload, { rejectWithValue }) => {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = (await res.json()) as RejectValue;
return rejectWithValue(err); // ← 業務エラーを型付きで返す
}
return (await res.json()) as LoginResponse;
});
const authSlice = createSlice({
name: "auth",
initialState: { token: null as string | null, error: null as string | null, loading: false },
reducers: { logout: (state) => { state.token = null; } },
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => { state.loading = true; state.error = null; })
.addCase(login.fulfilled, (state, action) => {
state.loading = false;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
// rejectWithValue経由のエラー本文を優先、なければJS例外メッセージ
state.error = action.payload?.message ?? action.error.message ?? "Unknown";
});
},
});
export const { logout } = authSlice.actions;
export default authSlice.reducer;
condition: 重複dispatchを防ぐ
「すでにloading中なら無視」「キャッシュがある時はスキップ」といった制御は、conditionオプションで実現できます。
// 例: すでにロード済みなら再フェッチしない
export const fetchUsers = createAsyncThunk<User[], void, { state: RootState }>(
"users/fetchUsers",
async () => { /* ... */ },
{
condition: (_arg, { getState }) => {
const status = getState().users.status;
if (status === "loading" || status === "succeeded") return false;
return true;
},
}
);
楽観的UI更新パターン
「いいねボタンを押した瞬間にUIを更新し、APIが失敗したらロールバック」――いわゆる楽観的更新(optimistic update)は、createAsyncThunkとextraReducersの組み合わせで実装できます。
// src/features/posts/postsSlice.ts(楽観的いいね)
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
interface Post { id: string; title: string; liked: boolean; }
export const toggleLike = createAsyncThunk<
void,
{ id: string; nextLiked: boolean },
{ rejectValue: { id: string; prevLiked: boolean } }
>("posts/toggleLike", async ({ id, nextLiked }, { rejectWithValue }) => {
const res = await fetch(`/api/posts/${id}/like`, {
method: "POST",
body: JSON.stringify({ liked: nextLiked }),
});
if (!res.ok) return rejectWithValue({ id, prevLiked: !nextLiked });
});
const postsSlice = createSlice({
name: "posts",
initialState: { items: [] as Post[] },
reducers: {},
extraReducers: (builder) => {
builder
// pending: UIを先に更新(楽観的)
.addCase(toggleLike.pending, (state, action) => {
const { id, nextLiked } = action.meta.arg;
const p = state.items.find((p) => p.id === id);
if (p) p.liked = nextLiked;
})
// rejected: 失敗したら元に戻す
.addCase(toggleLike.rejected, (state, action) => {
const fallback = action.payload;
if (!fallback) return;
const p = state.items.find((p) => p.id === fallback.id);
if (p) p.liked = fallback.prevLiked;
});
},
});
export default postsSlice.reducer;
createEntityAdapter: 正規化stateの標準
「TODOが1万件あって、IDで1件だけ更新したい」――こんな時、配列を毎回findするのは非効率です。createEntityAdapterは「IDで引けるオブジェクト」+「順序を保つID配列」という正規化stateを自動生成し、追加・更新・削除・selectorまで一式用意してくれます。
基本形: 正規化されたtodos
// src/features/todos/todosNormalizedSlice.ts
import {
createSlice,
createEntityAdapter,
type PayloadAction,
nanoid,
} from "@reduxjs/toolkit";
interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
// sortComparerで「新しい順」に並べる
const todosAdapter = createEntityAdapter<Todo>({
sortComparer: (a, b) => b.createdAt - a.createdAt,
});
const initialState = todosAdapter.getInitialState({
filter: "all" as "all" | "active" | "completed",
});
const todosSlice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo: {
reducer: todosAdapter.addOne,
prepare: (text: string) => ({
payload: { id: nanoid(), text, completed: false, createdAt: Date.now() } as Todo,
}),
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.entities[action.payload];
if (todo) todo.completed = !todo.completed;
},
removeTodo: todosAdapter.removeOne,
upsertMany: todosAdapter.upsertMany, // 既存はupdate / 新規はadd
},
});
// adapterが自動生成するselector(IDで引く・全件・件数)
export const todosSelectors = todosAdapter.getSelectors<{ todos: ReturnType<typeof todosSlice.reducer> }>(
(state) => state.todos
);
export const { addTodo, toggleTodo, removeTodo, upsertMany } = todosSlice.actions;
export default todosSlice.reducer;
adapter selectorの使い方
// コンポーネント側
import { todosSelectors } from "./todosNormalizedSlice";
import { useAppSelector } from "../../app/hooks";
function TodoList() {
const allTodos = useAppSelector(todosSelectors.selectAll);
const total = useAppSelector(todosSelectors.selectTotal);
// 特定idで引く: useAppSelector(s => todosSelectors.selectById(s, "xyz"))
return (
<div>
<p>全{total}件</p>
<ul>{allTodos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
</div>
);
}
派生stateとメモ化selectorの設計
「未完了TODOの件数だけ欲しい」「カート合計金額」のように派生的に計算するstateは、毎レンダリングで計算すると無駄が出ます。createSelector(reselect)で引数が変わった時だけ再計算するメモ化selectorを定義しましょう。
createSelectorでメモ化
// src/features/cart/cartSelectors.ts
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../../app/store";
// 入力selector
const selectCartItems = (state: RootState) => state.cart.items;
const selectProducts = (state: RootState) => state.products.entities;
// 派生selector(入力が変わるまでキャッシュされる)
export const selectCartTotal = createSelector(
[selectCartItems, selectProducts],
(items, products) =>
items.reduce((sum, item) => {
const p = products[item.productId];
return sum + (p?.price ?? 0) * item.qty;
}, 0)
);
export const selectCartCount = createSelector(
[selectCartItems],
(items) => items.reduce((sum, i) => sum + i.qty, 0)
);
useAppSelectorで使う
function CartBadge() {
const total = useAppSelector(selectCartTotal);
const count = useAppSelector(selectCartCount);
return <span>{count}点 / ¥{total.toLocaleString()}</span>;
}
RTK Query: サーバー状態管理の決定版
「APIキャッシュ・自動再フェッチ・タグ無効化・楽観的更新・ポーリング・無限スクロール」までを1つのAPI定義で済ませる――それがRTK Queryです。createAsyncThunkを大量に書くよりも、データフェッチに関してはほぼRTK Query一択と言えるレベルまで成熟しています。
API定義: createApi基本形
// src/services/api.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({
baseUrl: "https://jsonplaceholder.typicode.com/",
prepareHeaders: (headers, { getState }) => {
// 認証トークンを自動付与
const token = (getState() as any).auth?.token;
if (token) headers.set("authorization", `Bearer ${token}`);
return headers;
},
}),
tagTypes: ["Post"],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => "posts",
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: "Post" as const, id })),
{ type: "Post", id: "LIST" },
]
: [{ type: "Post", id: "LIST" }],
}),
getPostById: builder.query<Post, number>({
query: (id) => `posts/${id}`,
providesTags: (_r, _e, id) => [{ type: "Post", id }],
}),
addPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({ url: "posts", method: "POST", body }),
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
updatePost: builder.mutation<Post, Partial<Post> & Pick<Post, "id">>({
query: ({ id, ...patch }) => ({ url: `posts/${id}`, method: "PATCH", body: patch }),
invalidatesTags: (_r, _e, { id }) => [{ type: "Post", id }],
}),
deletePost: builder.mutation<void, number>({
query: (id) => ({ url: `posts/${id}`, method: "DELETE" }),
invalidatesTags: [{ type: "Post", id: "LIST" }],
}),
}),
});
export const {
useGetPostsQuery,
useGetPostByIdQuery,
useAddPostMutation,
useUpdatePostMutation,
useDeletePostMutation,
} = api;
storeへの統合
// src/app/store.ts(RTK Query統合版)
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { api } from "../services/api";
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
},
middleware: (getDefault) => getDefault().concat(api.middleware),
});
// refetchOnFocus / refetchOnReconnect を有効化
setupListeners(store.dispatch);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
useQueryフックでの取得
// src/features/posts/PostList.tsx
import { useGetPostsQuery, useDeletePostMutation } from "../../services/api";
export function PostList() {
const { data, isLoading, isError, error, refetch } = useGetPostsQuery();
const [deletePost, { isLoading: isDeleting }] = useDeletePostMutation();
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {JSON.stringify(error)}</p>;
return (
<div>
<button onClick={() => refetch()}>再読み込み</button>
<ul>
{data?.map((p) => (
<li key={p.id}>
{p.title}
<button disabled={isDeleting} onClick={() => deletePost(p.id)}>削除</button>
</li>
))}
</ul>
</div>
);
}
useMutation: 楽観的更新付き
// src/features/posts/EditPost.tsx
import { useUpdatePostMutation, api } from "../../services/api";
import { useAppDispatch } from "../../app/hooks";
export function EditPost({ id }: { id: number }) {
const [updatePost] = useUpdatePostMutation();
const dispatch = useAppDispatch();
const handleSave = async (newTitle: string) => {
// キャッシュを先に書き換える
const patchResult = dispatch(
api.util.updateQueryData("getPostById", id, (draft) => {
draft.title = newTitle;
})
);
try {
await updatePost({ id, title: newTitle }).unwrap();
} catch {
// 失敗したら巻き戻す
patchResult.undo();
}
};
return <button onClick={() => handleSave("新タイトル")}>保存</button>;
}
無限スクロール: serializeQueryArgs + merge
RTK Queryで無限スクロールを実装するには、同じキャッシュキーに後続データをmergeするテクニックを使います。
// src/services/feedApi.ts(無限スクロール用エンドポイント)
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
interface FeedItem { id: number; title: string; }
export const feedApi = createApi({
reducerPath: "feedApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api/" }),
endpoints: (builder) => ({
getFeed: builder.query<FeedItem[], number>({
query: (page) => `feed?page=${page}`,
// pageが違っても同じキャッシュエントリを使う
serializeQueryArgs: ({ endpointName }) => endpointName,
// 結果をマージ
merge: (currentCache, newItems) => {
currentCache.push(...newItems);
},
// pageが変わったときだけ再フェッチ
forceRefetch: ({ currentArg, previousArg }) => currentArg !== previousArg,
}),
}),
});
export const { useGetFeedQuery } = feedApi;
middleware: listenerMiddlewareで副作用を扱う
「actionが発火したら別の処理をしたい」「Sliceをまたいだ反応」には、createListenerMiddlewareが使えます。redux-sagaやredux-observableを使うほどではないが、副作用処理を一元化したい時の軽量middlewareです。
基本形: ログ出力リスナー
// src/app/listenerMiddleware.ts
import { createListenerMiddleware, isAnyOf } from "@reduxjs/toolkit";
import type { RootState, AppDispatch } from "./store";
import { login, logout } from "../features/auth/authSlice";
export const listenerMiddleware = createListenerMiddleware();
const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>();
// loginが成功したらlocalStorageにトークン保存
startAppListening({
actionCreator: login.fulfilled,
effect: async (action, listenerApi) => {
localStorage.setItem("token", action.payload.token);
console.log("[auth] login success", action.payload);
},
});
// logoutでクリア
startAppListening({
actionCreator: logout,
effect: () => {
localStorage.removeItem("token");
},
});
// 複数actionを同時に購読
startAppListening({
matcher: isAnyOf(login.fulfilled, logout),
effect: (action) => {
// ここでGAイベント送信など
window.dispatchEvent(new CustomEvent("auth-change", { detail: action.type }));
},
});
storeに登録
// src/app/store.ts
import { listenerMiddleware } from "./listenerMiddleware";
export const store = configureStore({
reducer: { /* ... */ },
middleware: (getDefault) =>
getDefault().prepend(listenerMiddleware.middleware), // ← prepend推奨
});
カスタムmiddleware: API呼び出しロガー
// 自前のmiddlewareを書く場合(古典的な書き方も依然サポートされる)
import type { Middleware } from "@reduxjs/toolkit";
export const apiLogger: Middleware = (storeApi) => (next) => (action) => {
if (typeof action === "object" && action !== null && "type" in action) {
if (String(action.type).endsWith("/pending")) console.group(action.type);
if (String(action.type).endsWith("/fulfilled")) {
console.log("payload:", (action as any).payload);
console.groupEnd();
}
}
return next(action);
};
永続化・テスト・デバッグの実践
本番運用に近づくほど重要になる「localStorage連携」「テスト」「Redux DevTools活用」の3点を、実コード付きで押さえます。
localStorageへの簡易永続化
redux-persistを入れる前に、まずはlistenerMiddlewareでselectiveに保存するアプローチが軽量で人気です。
// src/app/persistListener.ts
import { listenerMiddleware } from "./listenerMiddleware";
import { addTodo, toggleTodo, removeTodo } from "../features/todos/todosSlice";
import { isAnyOf } from "@reduxjs/toolkit";
listenerMiddleware.startListening({
matcher: isAnyOf(addTodo, toggleTodo, removeTodo),
effect: (_action, api) => {
const state = api.getState() as any;
localStorage.setItem("todos", JSON.stringify(state.todos.items));
},
});
// 起動時に復元(store作成時にpreloadedStateとして渡す)
export function loadTodos() {
try {
const raw = localStorage.getItem("todos");
return raw ? { items: JSON.parse(raw), filter: "all" } : undefined;
} catch {
return undefined;
}
}
storeにpreloadedStateを渡す
// src/app/store.ts(永続化対応版)
import { loadTodos } from "./persistListener";
export const store = configureStore({
reducer: { todos: todosReducer /* ... */ },
preloadedState: {
todos: loadTodos(),
},
});
Sliceの単体テスト(Vitest)
RTKのSliceは純粋関数(reducer + actions)なので、テストが非常に書きやすいのが大きな利点です。
// src/features/counter/counterSlice.test.ts
import { describe, it, expect } from "vitest";
import reducer, { increment, decrement, incrementByAmount } from "./counterSlice";
describe("counterSlice", () => {
it("初期state", () => {
expect(reducer(undefined, { type: "@@INIT" })).toEqual({ value: 0 });
});
it("increment で +1", () => {
expect(reducer({ value: 0 }, increment())).toEqual({ value: 1 });
});
it("decrement で -1", () => {
expect(reducer({ value: 3 }, decrement())).toEqual({ value: 2 });
});
it("incrementByAmount で +N", () => {
expect(reducer({ value: 0 }, incrementByAmount(5))).toEqual({ value: 5 });
});
});
createAsyncThunkのテスト
// src/features/users/usersSlice.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { configureStore } from "@reduxjs/toolkit";
import usersReducer, { fetchUsers } from "./usersSlice";
describe("fetchUsers", () => {
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([{ id: 1, name: "A", email: "a@a.com" }]),
} as Response)
);
});
it("成功時にlistへ反映", async () => {
const store = configureStore({ reducer: { users: usersReducer } });
await store.dispatch(fetchUsers());
expect(store.getState().users.list).toHaveLength(1);
expect(store.getState().users.status).toBe("succeeded");
});
});
Redux DevToolsの活用
RTKはconfigureStoreを使うだけでRedux DevTools拡張を自動接続します。Chromeの「Redux」タブで、actionの履歴・state diff・タイムトラベル(過去のstateに戻す)がそのまま使えます。バグ再現に圧倒的に強くなるため、開発時は必ずインストールしておきましょう。
RTK実装でやりがちなアンチパターンと対策
最後に、レビューで頻出する「動くけど良くない書き方」を整理します。同じ機能を実装するなら、最初から良いパターンで書いた方が後工程の負担が劇的に減ります。
レビューで頻出するNG行動5つ
- storeに全部突っ込む: ローカルで完結する状態(フォーム入力中・モーダル開閉)までReduxに上げてしまう。useStateで十分な領域はuseStateに残す。
- 1Slice 500行超え: 機能が肥大化したらSliceを分割する。1Sliceは100〜200行を目安に。
- actionTypeを手で書く: createSliceがすべて自動生成する。手書きのactionTypeはタイポリスク。
- useEffectでdispatch祭り: サーバー状態はRTK Queryへ。useEffect + dispatch + fetchの組み合わせは負債化しやすい。
- any型のuseSelector: 必ずTypedUseSelectorHookで型付きHookを定義する。anyはRTKの最大の恩恵を捨てる。
NG例とOK例の対比
| NGパターン | 問題点 | OKパターン |
|---|---|---|
useSelector(s => s)で全state取得 |
どこかが変わるたびに全コンポーネント再レンダリング | 必要なフィールドだけselectorで切り出す |
| actionから直接APIを叩く | テスト不能・副作用が散在 | createAsyncThunkに集約する |
| 派生stateをstateに保存 | 同期がズレてバグの温床 | createSelectorで派生計算する |
| 大きな配列を1Sliceに保持 | find/filter多用でO(n)が頻発 | createEntityAdapterで正規化 |
| サーバー状態もcreateSliceで管理 | キャッシュ・再取得・無効化を自前実装 | RTK Queryに任せる |
Before/After: selector最適化
// ❌ Before: state全体を取り出して毎回計算
function CartBadge() {
const state = useAppSelector((s) => s); // ← 全state購読
const total = state.cart.items.reduce(
(sum, i) => sum + state.products.entities[i.productId].price * i.qty,
0
);
return <span>¥{total}</span>;
}
// ✅ After: メモ化selectorで必要な値だけ購読
import { selectCartTotal } from "./cartSelectors";
function CartBadge() {
const total = useAppSelector(selectCartTotal);
return <span>¥{total.toLocaleString()}</span>;
}
Before/After: 非同期処理
// ❌ Before: コンポーネントでfetchしてdispatch
function UserList() {
const dispatch = useAppDispatch();
useEffect(() => {
fetch("/api/users").then((r) => r.json()).then((users) => {
dispatch({ type: "users/setAll", payload: users }); // 型なし・ローディングなし
});
}, []);
// ...
}
// ✅ After: createAsyncThunkでpending/fulfilled/rejected管理
function UserList() {
const dispatch = useAppDispatch();
const { list, status, error } = useAppSelector((s) => s.users);
useEffect(() => { if (status === "idle") dispatch(fetchUsers()); }, [status]);
// ローディング・エラーUIまでstateで完結
}
RTK主要API早見表
暗記すべき主要APIを一覧化します。実装中に「あれ、どっちだっけ」となったらこの表を見返してください。
用途別の最初に触るAPI早見表
| やりたいこと | 使うAPI | 所要時間目安 |
|---|---|---|
| UI状態(モーダル開閉・タブ)を共有 | createSlice + useAppSelector | 15分 |
| APIをfetchしてstateに反映 | createAsyncThunk + extraReducers | 30分 |
| 1万件のリストを高速更新 | createEntityAdapter | 30分 |
| 派生値(合計・件数)を計算 | createSelector | 15分 |
| サーバー状態・キャッシュ管理 | createApi(RTK Query) | 60分 |
| action発火後の副作用 | createListenerMiddleware | 20分 |
| localStorage永続化 | listenerMiddleware + preloadedState | 20分 |
主要API詳細リファレンス
| API | 用途 | 主要オプション |
|---|---|---|
configureStore |
store生成 | reducer / middleware / devTools / preloadedState |
createSlice |
reducer+action一括生成 | name / initialState / reducers / extraReducers |
createAsyncThunk |
非同期処理 | typePrefix / payloadCreator / condition / rejectWithValue |
createEntityAdapter |
正規化state | selectId / sortComparer / addOne / upsertMany |
createSelector |
メモ化派生state | 入力selector配列 / 結合関数 |
createApi |
RTK Query API定義 | baseQuery / endpoints / tagTypes / keepUnusedDataFor |
createListenerMiddleware |
副作用middleware | actionCreator / matcher / predicate / effect |
combineReducers |
reducer合成(通常不要) | configureStoreが内部で実行 |
収益化を目指すなら学習効率を最優先に
本記事を最後まで読み切れた人は、RTKの実装力はすでに現場標準を超えています。あとは「実プロダクト規模で書ききる経験」と「コードレビューを受ける環境」さえあれば、年収レンジは確実に上がるフェーズに入ります。短期間で実務レベルに到達したいなら、カリキュラム化された環境+メンターを使うのが最短ルートです。以下は2026年時点で評価が安定しているスクール・エージェントです。
- テックアカデミー — オンライン完結、現役エンジニアによる週2メンタリング。フロントエンドコースでReact + 状態管理ライブラリを実装課題ベースで学べる。
- 侍エンジニア — 完全マンツーマン。「自分のポートフォリオでRTK + RTK Queryを使い切る」というカスタムカリキュラムを組める柔軟さが強み。
- DMM WEBCAMP — 短期集中型の転職支援込みコース。実務でRTKを採用する企業への転職実績が豊富。
- レバテックキャリア — React/TypeScript案件に強い転職エージェント。RTK経験者は単価が跳ね上がるため、市場価値を測る面談を一度受ける価値が高い。
よくある質問(FAQ)
Q1. RTKとZustand、どちらを選ぶべきですか?
チーム人数・保守期間・型安全要求のいずれかが高いならRTK、個人開発・スタートアップMVPで「速さ最優先」ならZustandがベターです。詳細な判断基準は状態管理ライブラリ比較記事を参照してください。
Q2. RTK QueryとTanStack Query、どちらを使うべきですか?
すでにRTKをstoreに入れているならRTK Queryで統合した方が依存関係がシンプルです。Reduxを入れたくないプロジェクトならTanStack Queryを選びましょう。両者の機能差はほぼなく、思想と統合性で選ぶのが正解です。
Q3. createSliceのstate.x = yは本当に安全なんですか?
はい。RTKは内部でImmerを使い、与えられたstate関数の中で行ったmutationを新しいオブジェクトの生成に変換します。外から見ると常にimmutableなまま、書きやすさだけが手に入る設計です。
Q4. extraReducersをbuilder.addCaseで書く理由は?
オブジェクト形式はTypeScriptで型推論が効きにくいため、現在はbuilder形式が公式推奨です。addCaseはaction creatorと型が結びつくため、payloadが自動で型付けされます。
Q5. RTK Queryのキャッシュ保持時間はどう制御しますか?
keepUnusedDataForオプション(デフォルト60秒)で「最後の購読者がいなくなってからキャッシュを保持する秒数」を制御します。リアルタイム性が必要なら短く、テーブルマスタ等は長くと、エンドポイント単位で調整できます。
Q6. middlewareでaction.typeを文字列マッチするのは古いですか?
古いです。isAnyOf(actionA, actionB)やmatcher: actionA.matchを使う方が、型安全かつactionCreatorのrenameに追随します。文字列マッチは「endsWith(‘/pending’)」のような汎用ログだけに留めましょう。
Q7. RTKは学習コストが高いと聞きますが本当ですか?
「Redux時代の知識」が前提なら高いです。RTKから始める場合はむしろ低コストで、Zustand経験者なら数時間で実用できます。本記事のサンプルを上から順に手で写経すれば、平日3日で一通り書けるようになる規模感です。
まとめ: 実装力で差をつけるRTK
本記事ではRedux Toolkit 使い方を、environment setup → store → createSlice → createAsyncThunk → createEntityAdapter → createSelector → RTK Query → listener middleware → 永続化 → テスト → アンチパターン、と一気通貫で実装コード中心に解説しました。RTKは「ボイラープレートを減らし、型安全とDevToolsで武装したRedux」であり、中〜大規模プロジェクトの状態管理デファクトです。useContext・useReducer・状態管理比較を踏まえて「ここはRTKに移行すべき」と判断できたら、本記事のサンプルを順に組み立てるだけで実装が完成します。あとは実プロダクトでの反復経験です。書ける機能を1つでも増やし、自分の市場価値を確実に上げていきましょう。

コメント