10日で覚えるReduxDay 2: Redux Toolkitの基本
books.chapter 210日で覚えるRedux

Day 2: Redux Toolkitの基本

今日学ぶこと

  • Redux ToolkitとReact-Reduxのインストール方法
  • configureStore でStoreを作成する
  • createSlice でstate、reducers、actionsを定義する
  • カウンターアプリをステップバイステップで構築する
  • ToDoリストアプリを構築する
  • ProvideruseSelectoruseDispatch の使い方
  • Immerによるイミュータブル更新の仕組み
  • レガシーReduxとRTKのコード比較

インストール

Redux Toolkitを使うには、2つのパッケージが必要です。

npm install @reduxjs/toolkit react-redux
パッケージ 役割
@reduxjs/toolkit Redux本体 + 便利なユーティリティ(createSlice, configureStore等)
react-redux ReactとReduxを接続するバインディング(Provider, useSelector, useDispatch等)

configureStore: Storeの作成

configureStore は、Redux Storeを作成するための関数です。レガシーReduxの createStore に代わるもので、以下が自動的に設定されます。

  • Redux DevTools Extension との接続
  • redux-thunk ミドルウェア(非同期処理用)
  • 開発時のチェック(状態の直接変更検出、シリアライズ不可能な値の検出)
import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({
  reducer: {
    // ここにsliceのreducerを登録する
  }
});

export default store;
TypeScript版
import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({
  reducer: {
    // ここにsliceのreducerを登録する
  }
});

// Store自体から型を推論する
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;

createSlice: スライスの定義

createSlice はRedux Toolkitの中核となるAPIです。1つの関数呼び出しで以下をすべて定義できます。

  • 初期状態 (initialState)
  • Reducer関数 (状態の更新ロジック)
  • Action Creator (自動生成)
  • Action Type (自動生成)
flowchart TB
    subgraph createSlice["createSlice()"]
        Name["name: 'counter'"]
        Initial["initialState: { value: 0 }"]
        Reducers["reducers: { increment, decrement, ... }"]
    end

    createSlice --> Actions["自動生成されるActions<br/>counter/increment<br/>counter/decrement"]
    createSlice --> Reducer["エクスポートされるReducer<br/>counterSlice.reducer"]

    Actions --> Dispatch["dispatch()で使用"]
    Reducer --> Store["configureStoreに登録"]

    style createSlice fill:#3b82f6,color:#fff
    style Actions fill:#f59e0b,color:#fff
    style Reducer fill:#22c55e,color:#fff

実践1: カウンターアプリ

最もシンプルなReduxアプリケーションとして、カウンターを構築しましょう。

Step 1: Sliceの作成

// src/features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0
  },
  reducers: {
    increment(state) {
      // Immerにより、直接「変更」するように書ける
      state.value += 1;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action) {
      state.value += action.payload;
    },
    reset(state) {
      state.value = 0;
    }
  }
});

// Action Creatorsをエクスポート
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;

// Reducerをエクスポート
export default counterSlice.reducer;
TypeScript版
// src/features/counter/counterSlice.ts
import { createSlice, 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;
    },
    decrement(state) {
      state.value -= 1;
    },
    incrementByAmount(state, action: PayloadAction<number>) {
      state.value += action.payload;
    },
    reset(state) {
      state.value = 0;
    }
  }
});

export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
export default counterSlice.reducer;

Step 2: Storeの設定

// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer
  }
});

export default store;
TypeScript版
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer
  }
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;

Step 3: Providerでアプリをラップ

Provider コンポーネントは、Reactコンポーネントツリー全体にRedux Storeを供給します。

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './app/store';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

Step 4: コンポーネントからStoreにアクセス

// src/features/counter/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount, reset } from './counterSlice';

function Counter() {
  // useSelector: Storeから状態を読み取る
  const count = useSelector((state) => state.counter.value);

  // useDispatch: Actionをdispatchするための関数を取得
  const dispatch = useDispatch();

  return (
    <div>
      <h1>カウンター: {count}</h1>
      <div>
        <button onClick={() => dispatch(increment())}>+1</button>
        <button onClick={() => dispatch(decrement())}>-1</button>
        <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
        <button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
        <button onClick={() => dispatch(reset())}>リセット</button>
      </div>
    </div>
  );
}

export default Counter;
TypeScript版
// src/features/counter/Counter.tsx
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount, reset } from './counterSlice';
import type { RootState } from '../../app/store';

function Counter() {
  const count = useSelector((state: RootState) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <h1>カウンター: {count}</h1>
      <div>
        <button onClick={() => dispatch(increment())}>+1</button>
        <button onClick={() => dispatch(decrement())}>-1</button>
        <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
        <button onClick={() => dispatch(incrementByAmount(10))}>+10</button>
        <button onClick={() => dispatch(reset())}>リセット</button>
      </div>
    </div>
  );
}

export default Counter;

データフローの確認

カウンターで「+1」ボタンをクリックしたときのフローを確認しましょう。

sequenceDiagram
    participant User as ユーザー
    participant UI as Counter コンポーネント
    participant Dispatch as dispatch()
    participant Reducer as counterSlice.reducer
    participant Store as Redux Store

    User->>UI: 「+1」ボタンをクリック
    UI->>Dispatch: dispatch(increment())
    Note over Dispatch: Action: { type: 'counter/increment' }
    Dispatch->>Reducer: Action を処理
    Reducer->>Store: state.value = 0 + 1 = 1
    Store->>UI: useSelector が新しい値を受け取る
    UI->>User: カウンター: 1 と表示

useSelector の詳細

useSelector はRedux Storeから状態を読み取るためのhookです。

基本的な使い方

// Store全体の状態から必要な部分だけを選択する
const count = useSelector((state) => state.counter.value);

重要なポイント: パフォーマンス最適化

useSelector参照の等価性でコンポーネントの再レンダリングを判断します。セレクタの戻り値が前回と同じ参照であれば、再レンダリングはスキップされます。

// GOOD: プリミティブ値を返す(値が同じなら再レンダリングされない)
const count = useSelector((state) => state.counter.value);
const userName = useSelector((state) => state.user.name);

// CAUTION: 新しいオブジェクトを返すと毎回再レンダリングされる
// (毎回新しいオブジェクト参照が生まれるため)
const userData = useSelector((state) => ({
  name: state.user.name,
  email: state.user.email,
}));
// ↑ これは毎回再レンダリングを引き起こす!

// GOOD: 複数の値が必要な場合は個別にセレクタを使う
const name = useSelector((state) => state.user.name);
const email = useSelector((state) => state.user.email);

useDispatch の詳細

useDispatch はActionをdispatchするための関数を返すhookです。

const dispatch = useDispatch();

// Action Creatorの戻り値をdispatchする
dispatch(increment());
// これは以下と同じ意味:
// dispatch({ type: 'counter/increment' })

dispatch(incrementByAmount(5));
// これは以下と同じ意味:
// dispatch({ type: 'counter/incrementByAmount', payload: 5 })

実践2: ToDoリストアプリ

より実践的な例として、ToDoリストアプリを構築しましょう。

Step 1: ToDo Sliceの作成

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

let nextId = 1;

const todosSlice = createSlice({
  name: 'todos',
  initialState: {
    items: [],
    filter: 'all' // 'all' | 'active' | 'completed'
  },
  reducers: {
    addTodo(state, action) {
      state.items.push({
        id: nextId++,
        text: action.payload,
        completed: false,
        createdAt: new Date().toISOString()
      });
    },
    toggleTodo(state, action) {
      const todo = state.items.find(item => item.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo(state, action) {
      state.items = state.items.filter(item => item.id !== action.payload);
    },
    editTodo(state, action) {
      const { id, text } = action.payload;
      const todo = state.items.find(item => item.id === id);
      if (todo) {
        todo.text = text;
      }
    },
    setFilter(state, action) {
      state.filter = action.payload;
    },
    clearCompleted(state) {
      state.items = state.items.filter(item => !item.completed);
    },
    toggleAll(state) {
      const allCompleted = state.items.every(item => item.completed);
      state.items.forEach(item => {
        item.completed = !allCompleted;
      });
    }
  }
});

export const {
  addTodo,
  toggleTodo,
  deleteTodo,
  editTodo,
  setFilter,
  clearCompleted,
  toggleAll
} = todosSlice.actions;

export default todosSlice.reducer;
TypeScript版
// src/features/todos/todosSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
  createdAt: string;
}

type FilterType = 'all' | 'active' | 'completed';

interface TodosState {
  items: Todo[];
  filter: FilterType;
}

const initialState: TodosState = {
  items: [],
  filter: 'all'
};

let nextId = 1;

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo(state, action: PayloadAction<string>) {
      state.items.push({
        id: nextId++,
        text: action.payload,
        completed: false,
        createdAt: new Date().toISOString()
      });
    },
    toggleTodo(state, action: PayloadAction<number>) {
      const todo = state.items.find(item => item.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
    deleteTodo(state, action: PayloadAction<number>) {
      state.items = state.items.filter(item => item.id !== action.payload);
    },
    editTodo(state, action: PayloadAction<{ id: number; text: string }>) {
      const { id, text } = action.payload;
      const todo = state.items.find(item => item.id === id);
      if (todo) {
        todo.text = text;
      }
    },
    setFilter(state, action: PayloadAction<FilterType>) {
      state.filter = action.payload;
    },
    clearCompleted(state) {
      state.items = state.items.filter(item => !item.completed);
    },
    toggleAll(state) {
      const allCompleted = state.items.every(item => item.completed);
      state.items.forEach(item => {
        item.completed = !allCompleted;
      });
    }
  }
});

export const {
  addTodo,
  toggleTodo,
  deleteTodo,
  editTodo,
  setFilter,
  clearCompleted,
  toggleAll
} = todosSlice.actions;

export default todosSlice.reducer;

Step 2: Storeに登録

// src/app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';

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

export default store;
TypeScript版
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todosReducer from '../features/todos/todosSlice';

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

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;

Step 3: ToDo入力コンポーネント

// src/features/todos/AddTodo.js
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { addTodo } from './todosSlice';

function AddTodo() {
  const [text, setText] = useState('');
  const dispatch = useDispatch();

  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      dispatch(addTodo(text.trim()));
      setText('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="タスクを入力..."
      />
      <button type="submit">追加</button>
    </form>
  );
}

export default AddTodo;
TypeScript版
// src/features/todos/AddTodo.tsx
import { useState, FormEvent } from 'react';
import { useDispatch } from 'react-redux';
import { addTodo } from './todosSlice';

function AddTodo() {
  const [text, setText] = useState('');
  const dispatch = useDispatch();

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (text.trim()) {
      dispatch(addTodo(text.trim()));
      setText('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="タスクを入力..."
      />
      <button type="submit">追加</button>
    </form>
  );
}

export default AddTodo;

Step 4: ToDoリスト表示コンポーネント

// src/features/todos/TodoList.js
import { useSelector, useDispatch } from 'react-redux';
import { toggleTodo, deleteTodo, setFilter, clearCompleted, toggleAll } from './todosSlice';

function TodoList() {
  const { items, filter } = useSelector((state) => state.todos);
  const dispatch = useDispatch();

  // フィルタリング
  const filteredItems = items.filter(item => {
    if (filter === 'active') return !item.completed;
    if (filter === 'completed') return item.completed;
    return true;
  });

  const activeCount = items.filter(item => !item.completed).length;
  const completedCount = items.filter(item => item.completed).length;

  return (
    <div>
      {/* フィルタボタン */}
      <div>
        <button
          onClick={() => dispatch(setFilter('all'))}
          style={{ fontWeight: filter === 'all' ? 'bold' : 'normal' }}
        >
          すべて ({items.length})
        </button>
        <button
          onClick={() => dispatch(setFilter('active'))}
          style={{ fontWeight: filter === 'active' ? 'bold' : 'normal' }}
        >
          未完了 ({activeCount})
        </button>
        <button
          onClick={() => dispatch(setFilter('completed'))}
          style={{ fontWeight: filter === 'completed' ? 'bold' : 'normal' }}
        >
          完了済み ({completedCount})
        </button>
      </div>

      {/* 一括操作 */}
      <div>
        <button onClick={() => dispatch(toggleAll())}>すべて切替</button>
        <button onClick={() => dispatch(clearCompleted())}>完了済みを削除</button>
      </div>

      {/* ToDoリスト */}
      <ul>
        {filteredItems.map(item => (
          <li key={item.id}>
            <input
              type="checkbox"
              checked={item.completed}
              onChange={() => dispatch(toggleTodo(item.id))}
            />
            <span style={{
              textDecoration: item.completed ? 'line-through' : 'none',
              color: item.completed ? '#999' : '#000'
            }}>
              {item.text}
            </span>
            <button onClick={() => dispatch(deleteTodo(item.id))}>削除</button>
          </li>
        ))}
      </ul>

      {filteredItems.length === 0 && (
        <p>タスクがありません</p>
      )}
    </div>
  );
}

export default TodoList;
TypeScript版
// src/features/todos/TodoList.tsx
import { useSelector, useDispatch } from 'react-redux';
import { toggleTodo, deleteTodo, setFilter, clearCompleted, toggleAll } from './todosSlice';
import type { RootState } from '../../app/store';

function TodoList() {
  const { items, filter } = useSelector((state: RootState) => state.todos);
  const dispatch = useDispatch();

  const filteredItems = items.filter(item => {
    if (filter === 'active') return !item.completed;
    if (filter === 'completed') return item.completed;
    return true;
  });

  const activeCount = items.filter(item => !item.completed).length;
  const completedCount = items.filter(item => item.completed).length;

  return (
    <div>
      <div>
        <button
          onClick={() => dispatch(setFilter('all'))}
          style={{ fontWeight: filter === 'all' ? 'bold' : 'normal' }}
        >
          すべて ({items.length})
        </button>
        <button
          onClick={() => dispatch(setFilter('active'))}
          style={{ fontWeight: filter === 'active' ? 'bold' : 'normal' }}
        >
          未完了 ({activeCount})
        </button>
        <button
          onClick={() => dispatch(setFilter('completed'))}
          style={{ fontWeight: filter === 'completed' ? 'bold' : 'normal' }}
        >
          完了済み ({completedCount})
        </button>
      </div>

      <div>
        <button onClick={() => dispatch(toggleAll())}>すべて切替</button>
        <button onClick={() => dispatch(clearCompleted())}>完了済みを削除</button>
      </div>

      <ul>
        {filteredItems.map(item => (
          <li key={item.id}>
            <input
              type="checkbox"
              checked={item.completed}
              onChange={() => dispatch(toggleTodo(item.id))}
            />
            <span style={{
              textDecoration: item.completed ? 'line-through' : 'none',
              color: item.completed ? '#999' : '#000'
            }}>
              {item.text}
            </span>
            <button onClick={() => dispatch(deleteTodo(item.id))}>削除</button>
          </li>
        ))}
      </ul>

      {filteredItems.length === 0 && (
        <p>タスクがありません</p>
      )}
    </div>
  );
}

export default TodoList;

Step 5: メインアプリコンポーネント

// src/App.js
import AddTodo from './features/todos/AddTodo';
import TodoList from './features/todos/TodoList';

function App() {
  return (
    <div>
      <h1>ToDoリスト</h1>
      <AddTodo />
      <TodoList />
    </div>
  );
}

export default App;

Immerの仕組み

Redux Toolkitが内部で使用しているImmerライブラリは、Redux開発体験を劇的に向上させます。

なぜImmerが重要なのか

Reduxでは、状態をイミュータブル(不変)に更新する必要があります。レガシーReduxでは、スプレッド構文を使って手動でイミュータブルな更新を行う必要がありました。

// レガシーRedux: 手動でイミュータブル更新(ネストが深いと地獄)
function reducer(state, action) {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        third: {
          ...state.first.second.third,
          value: action.payload
        }
      }
    }
  };
}

// Redux Toolkit (Immer): 直感的な「ミュータブル風」記法
function reducer(state, action) {
  state.first.second.third.value = action.payload;
}

Immerの動作原理

flowchart LR
    Original["元の状態<br/>(Frozen Object)"]
    Draft["Draft(下書き)<br/>(Proxy Object)"]
    New["新しい状態<br/>(New Frozen Object)"]

    Original -->|"Immerがプロキシを作成"| Draft
    Draft -->|"変更を記録"| Draft
    Draft -->|"変更を適用して<br/>新しいオブジェクトを生成"| New

    style Original fill:#3b82f6,color:#fff
    style Draft fill:#f59e0b,color:#fff
    style New fill:#22c55e,color:#fff
  1. Immerは元の状態のProxy(Draft)を作成する
  2. あなたのReducerコードはDraftに対して「変更」を行う
  3. Immerは行われた変更を追跡し、変更された部分だけを含む新しいオブジェクトを生成する
  4. 元の状態は一切変更されない

Immerの注意点

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    // OK: stateを直接変更する(Immerが処理する)
    addTodo(state, action) {
      state.push(action.payload);
    },

    // OK: 新しい値を返す(通常のReducerと同じ)
    clearAll() {
      return [];
    },

    // NG: stateを変更しつつ、新しい値も返す(エラーになる)
    // badReducer(state, action) {
    //   state.push(action.payload);
    //   return state; // これはNG!
    // }
  }
});

ルール: Reducerの中では「stateを変更する」か「新しい値を返す」かのどちらか一方だけを行ってください。両方同時に行うとエラーになります。


PayloadAction型

TypeScriptを使う場合、PayloadAction 型でActionのpayloadに型を付けることができます。

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface UserState {
  name: string;
  email: string;
  age: number;
}

const userSlice = createSlice({
  name: 'user',
  initialState: { name: '', email: '', age: 0 } as UserState,
  reducers: {
    // PayloadAction<T> でpayloadの型を指定
    setName(state, action: PayloadAction<string>) {
      state.name = action.payload; // action.payload は string 型
    },
    setEmail(state, action: PayloadAction<string>) {
      state.email = action.payload;
    },
    setAge(state, action: PayloadAction<number>) {
      state.age = action.payload; // action.payload は number 型
    },
    updateProfile(state, action: PayloadAction<Partial<UserState>>) {
      // action.payload は { name?: string, email?: string, age?: number }
      Object.assign(state, action.payload);
    }
  }
});

PayloadAction を使うことで、dispatch時にも正しい型が強制されます。

dispatch(setName('田中太郎'));        // OK
dispatch(setName(123));               // TypeScriptエラー!
dispatch(setAge(25));                 // OK
dispatch(setAge('twenty-five'));      // TypeScriptエラー!

レガシーRedux vs Redux Toolkit 比較

同じToDoアプリを、レガシーReduxとRTKで比較してみましょう。

レガシーRedux

// actionTypes.js
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const DELETE_TODO = 'DELETE_TODO';
export const SET_FILTER = 'SET_FILTER';

// actionCreators.js
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO, SET_FILTER } from './actionTypes';

let nextId = 1;
export const addTodo = (text) => ({
  type: ADD_TODO,
  payload: { id: nextId++, text, completed: false }
});
export const toggleTodo = (id) => ({ type: TOGGLE_TODO, payload: id });
export const deleteTodo = (id) => ({ type: DELETE_TODO, payload: id });
export const setFilter = (filter) => ({ type: SET_FILTER, payload: filter });

// reducer.js
import { ADD_TODO, TOGGLE_TODO, DELETE_TODO, SET_FILTER } from './actionTypes';

const initialState = { items: [], filter: 'all' };

export default function todosReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return { ...state, items: [...state.items, action.payload] };
    case TOGGLE_TODO:
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload
            ? { ...item, completed: !item.completed }
            : item
        )
      };
    case DELETE_TODO:
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    case SET_FILTER:
      return { ...state, filter: action.payload };
    default:
      return state;
  }
}

// store.js
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import todosReducer from './reducer';

export default createStore(
  combineReducers({ todos: todosReducer }),
  applyMiddleware(thunk)
);

ファイル数: 4ファイル、約60行

Redux Toolkit

// todosSlice.js
import { createSlice } from '@reduxjs/toolkit';

let nextId = 1;

const todosSlice = createSlice({
  name: 'todos',
  initialState: { items: [], filter: 'all' },
  reducers: {
    addTodo(state, action) {
      state.items.push({ id: nextId++, text: action.payload, completed: false });
    },
    toggleTodo(state, action) {
      const todo = state.items.find(item => item.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
    deleteTodo(state, action) {
      state.items = state.items.filter(item => item.id !== action.payload);
    },
    setFilter(state, action) {
      state.filter = action.payload;
    }
  }
});

export const { addTodo, toggleTodo, deleteTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;

// store.js
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from './todosSlice';

export default configureStore({
  reducer: { todos: todosReducer }
});

ファイル数: 2ファイル、約30行

比較まとめ

観点 レガシーRedux Redux Toolkit
ファイル数 4ファイル 2ファイル
コード量 約60行 約30行
Action Types 手動で定数定義 自動生成
Action Creators 手動で関数定義 自動生成
イミュータブル更新 スプレッド構文(手動) Immer(自動)
ミドルウェア設定 applyMiddleware(手動) 自動設定
DevTools 別途設定が必要 自動設定
型安全性 手動で型定義 PayloadAction で簡単

まとめ

概念 説明
configureStore Storeを作成する関数。DevTools、thunk、開発チェックが自動設定される
createSlice state、reducers、actionsを1つの関数で定義する
Provider ReactコンポーネントツリーにStoreを供給するコンポーネント
useSelector Storeから状態を読み取るhook。参照の等価性で再レンダリングを最適化
useDispatch Actionをdispatchするための関数を返すhook
Immer 「ミュータブル風」記法でイミュータブルな更新を実現するライブラリ
PayloadAction TypeScript用のAction型。payloadの型を指定できる
Slice state + reducers + actionsのまとまり。機能ごとに分割する

今日はRedux Toolkitの基本的なAPIを学び、カウンターアプリとToDoリストアプリを実際に構築しました。createSliceconfigureStore を使うことで、レガシーReduxと比較して大幅にコード量を削減でき、開発体験が向上することを体験しました。

明日のDay 3では、複数のSliceを組み合わせた実践的なアプリケーションと、セレクタの活用方法を学びます。


練習問題

問題1: ショッピングカートSlice

以下の機能を持つショッピングカートSliceを createSlice で作成してください。

  • addItem(product): 商品をカートに追加する(同じ商品がある場合は数量を+1)
  • removeItem(productId): 商品をカートから完全に削除する
  • updateQuantity({ productId, quantity }): 商品の数量を変更する
  • clearCart(): カートを空にする

初期状態:

{
  items: [],    // { id, name, price, quantity }
  totalItems: 0,
  totalPrice: 0
}

問題2: useSelector の最適化

以下のコードにはパフォーマンスの問題があります。問題を特定し、修正してください。

function CartSummary() {
  const cartData = useSelector((state) => ({
    items: state.cart.items,
    total: state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    count: state.cart.items.length,
  }));

  return (
    <div>
      <p>商品数: {cartData.count}</p>
      <p>合計: {cartData.total}</p>
    </div>
  );
}

問題3: Immerの理解

以下のReducerのうち、正しく動作するものと動作しないものを判別し、理由を説明してください。

const slice = createSlice({
  name: 'example',
  initialState: { items: [], count: 0 },
  reducers: {
    // A
    addItem(state, action) {
      state.items.push(action.payload);
      state.count += 1;
    },
    // B
    resetItems(state) {
      return { ...state, items: [], count: 0 };
    },
    // C
    badReset(state) {
      state.items = [];
      return state;
    },
    // D
    replaceAll(state, action) {
      return { items: action.payload, count: action.payload.length };
    }
  }
});