10日で覚えるReduxDay 8: セレクタとパフォーマンス
books.chapter 810日で覚えるRedux

Day 8: セレクタとパフォーマンス

今日学ぶこと

  • セレクタの概念と役割
  • シンプルセレクタとメモ化セレクタの違い
  • createSelector によるメモ化の仕組み
  • セレクタの合成(コンポジション)
  • useSelector と再レンダリングの関係
  • よくあるパフォーマンスの落とし穴
  • createEntityAdapter のセレクタ
  • パフォーマンスデバッグの方法

セレクタとは

セレクタは、Redux ストアの状態から必要なデータを取り出す関数です。コンポーネントが直接 state の構造に依存しないようにする「抽象化レイヤー」として機能します。

flowchart LR
    subgraph Store["Redux Store"]
        S["State"]
    end

    subgraph Selectors["セレクタ"]
        S1["selectTodos"]
        S2["selectFilter"]
        S3["selectFilteredTodos"]
    end

    subgraph Component["コンポーネント"]
        C["TodoList"]
    end

    S --> S1
    S --> S2
    S1 --> S3
    S2 --> S3
    S3 --> C

    style Store fill:#3b82f6,color:#fff
    style Selectors fill:#8b5cf6,color:#fff
    style Component fill:#22c55e,color:#fff

なぜセレクタを使うのか

メリット 説明
カプセル化 state の構造が変わっても、セレクタだけ修正すればよい
再利用性 複数のコンポーネントで同じデータ取得ロジックを共有
テスト容易性 純粋関数なのでテストが簡単
パフォーマンス メモ化によって不要な再計算を防止
可読性 データ取得の意図が関数名に表れる

シンプルセレクタ

最も基本的なセレクタは、state を受け取って一部を返すだけの関数です。

// state の特定部分を取り出すセレクタ
const selectTodos = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;
const selectUserName = (state) => state.user.name;

// コンポーネントで使用
function TodoList() {
  const todos = useSelector(selectTodos);
  const filter = useSelector(selectFilter);

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}
TypeScript版
import { useSelector } from 'react-redux';
import type { RootState } from './store';

const selectTodos = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;
const selectUserName = (state: RootState) => state.user.name;

function TodoList() {
  const todos = useSelector(selectTodos);
  const filter = useSelector(selectFilter);

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

Slice でセレクタを定義する

RTK では、スライスファイル内にセレクタを定義するのが推奨パターンです。

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

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

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

// Selectors
export const selectTodos = (state) => state.todos.items;
export const selectFilter = (state) => state.todos.filter;

export default todosSlice.reducer;
TypeScript版
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../../store';

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

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

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

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

const todosSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: (state, action: PayloadAction<string>) => {
      state.items.push({
        id: Date.now(),
        text: action.payload,
        completed: false,
      });
    },
    toggleTodo: (state, action: PayloadAction<number>) => {
      const todo = state.items.find((t) => t.id === action.payload);
      if (todo) todo.completed = !todo.completed;
    },
    setFilter: (state, action: PayloadAction<FilterType>) => {
      state.filter = action.payload;
    },
  },
});

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

export const selectTodos = (state: RootState) => state.todos.items;
export const selectFilter = (state: RootState) => state.todos.filter;

export default todosSlice.reducer;

createSelector — メモ化セレクタ

createSelector は、Reselect ライブラリ(RTK に同梱)が提供するメモ化セレクタの作成関数です。入力セレクタの結果が変わらない限り、以前の計算結果をキャッシュから返します。

メモ化の仕組み

flowchart TB
    subgraph Input["入力セレクタ"]
        IS1["selectTodos(state)"]
        IS2["selectFilter(state)"]
    end

    subgraph Check["参照比較"]
        C{"前回と同じ?"}
    end

    subgraph Output["出力セレクタ"]
        OS["filteredTodos を計算"]
    end

    subgraph Cache["キャッシュ"]
        CR["前回の結果を返す"]
    end

    IS1 --> C
    IS2 --> C
    C -->|"No"| OS
    C -->|"Yes"| CR

    style Input fill:#3b82f6,color:#fff
    style Check fill:#f59e0b,color:#fff
    style Output fill:#8b5cf6,color:#fff
    style Cache fill:#22c55e,color:#fff

基本的な使い方

import { createSelector } from '@reduxjs/toolkit';

const selectTodos = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;

// メモ化セレクタ
const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    // この関数は、todos か filter が変わった時だけ実行される
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);
TypeScript版
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';

const selectTodos = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;

const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);

メモ化がないとどうなるか

// BAD: 毎回新しい配列を返す → 毎回再レンダリング
function TodoList() {
  const activeTodos = useSelector((state) =>
    state.todos.items.filter((t) => !t.completed)
  );
  // activeTodos は毎回新しい配列参照になるため、
  // 内容が同じでも再レンダリングが発生する
}

// GOOD: メモ化セレクタを使用
function TodoList() {
  const activeTodos = useSelector(selectFilteredTodos);
  // todos/filter が変わらない限り同じ参照が返る
}

セレクタの合成

メモ化セレクタは他のセレクタを入力として使えるため、段階的に複雑なデータを組み立てられます。

import { createSelector } from '@reduxjs/toolkit';

// Level 1: シンプルセレクタ
const selectTodos = (state) => state.todos.items;
const selectFilter = (state) => state.todos.filter;
const selectSearchQuery = (state) => state.todos.searchQuery;

// Level 2: フィルタ適用
const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);

// Level 3: 検索適用(Level 2 の結果を使う)
const selectSearchedTodos = createSelector(
  [selectFilteredTodos, selectSearchQuery],
  (filteredTodos, query) => {
    if (!query) return filteredTodos;
    const lowerQuery = query.toLowerCase();
    return filteredTodos.filter((t) =>
      t.text.toLowerCase().includes(lowerQuery)
    );
  }
);

// Level 4: 統計情報(複数のセレクタを組み合わせ)
const selectTodoStats = createSelector(
  [selectTodos],
  (todos) => ({
    total: todos.length,
    active: todos.filter((t) => !t.completed).length,
    completed: todos.filter((t) => t.completed).length,
    completionRate: todos.length > 0
      ? Math.round((todos.filter((t) => t.completed).length / todos.length) * 100)
      : 0,
  })
);
TypeScript版
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';

const selectTodos = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;
const selectSearchQuery = (state: RootState) => state.todos.searchQuery;

const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);

const selectSearchedTodos = createSelector(
  [selectFilteredTodos, selectSearchQuery],
  (filteredTodos, query) => {
    if (!query) return filteredTodos;
    const lowerQuery = query.toLowerCase();
    return filteredTodos.filter((t) =>
      t.text.toLowerCase().includes(lowerQuery)
    );
  }
);

interface TodoStats {
  total: number;
  active: number;
  completed: number;
  completionRate: number;
}

const selectTodoStats = createSelector(
  [selectTodos],
  (todos): TodoStats => ({
    total: todos.length,
    active: todos.filter((t) => !t.completed).length,
    completed: todos.filter((t) => t.completed).length,
    completionRate: todos.length > 0
      ? Math.round((todos.filter((t) => t.completed).length / todos.length) * 100)
      : 0,
  })
);
flowchart TB
    subgraph L1["Level 1: シンプルセレクタ"]
        S1["selectTodos"]
        S2["selectFilter"]
        S3["selectSearchQuery"]
    end

    subgraph L2["Level 2: フィルタ適用"]
        S4["selectFilteredTodos"]
    end

    subgraph L3["Level 3: 検索適用"]
        S5["selectSearchedTodos"]
    end

    subgraph L4["Level 4: 統計"]
        S6["selectTodoStats"]
    end

    S1 --> S4
    S2 --> S4
    S4 --> S5
    S3 --> S5
    S1 --> S6

    style L1 fill:#3b82f6,color:#fff
    style L2 fill:#8b5cf6,color:#fff
    style L3 fill:#22c55e,color:#fff
    style L4 fill:#f59e0b,color:#fff

useSelector と再レンダリング

参照等価性チェック

useSelector は、セレクタの返り値を前回の値と 参照等価性(=== で比較します。値が異なる場合のみ、コンポーネントが再レンダリングされます。

// 参照が変わらない → 再レンダリングしない
const name = useSelector((state) => state.user.name);
// string は値比較なので、同じ文字列なら再レンダリングしない

// 毎回新しいオブジェクト → 毎回再レンダリング!
const user = useSelector((state) => ({
  name: state.user.name,
  email: state.user.email,
}));
// {} !== {} なので、内容が同じでも毎回再レンダリング

解決策

方法 1: 複数の useSelector に分ける

function UserProfile() {
  const name = useSelector((state) => state.user.name);
  const email = useSelector((state) => state.user.email);

  return (
    <div>
      <p>{name}</p>
      <p>{email}</p>
    </div>
  );
}

方法 2: createSelector でメモ化

const selectUserProfile = createSelector(
  [(state) => state.user.name, (state) => state.user.email],
  (name, email) => ({ name, email })
);

function UserProfile() {
  const { name, email } = useSelector(selectUserProfile);
  // name, email が変わらない限り同じオブジェクト参照が返る
  return (
    <div>
      <p>{name}</p>
      <p>{email}</p>
    </div>
  );
}

方法 3: shallowEqual を使う

import { useSelector, shallowEqual } from 'react-redux';

function UserProfile() {
  const { name, email } = useSelector(
    (state) => ({
      name: state.user.name,
      email: state.user.email,
    }),
    shallowEqual // 浅い比較を使用
  );

  return (
    <div>
      <p>{name}</p>
      <p>{email}</p>
    </div>
  );
}

よくあるパフォーマンスの落とし穴

1. インラインセレクタで新しい参照を作る

// BAD: filter() は毎回新しい配列を作る
function ActiveTodos() {
  const activeTodos = useSelector((state) =>
    state.todos.items.filter((t) => !t.completed)
  );
  // 他の state が変わっても再レンダリングされてしまう
}

// GOOD: createSelector を使う
const selectActiveTodos = createSelector(
  [(state) => state.todos.items],
  (todos) => todos.filter((t) => !t.completed)
);

function ActiveTodos() {
  const activeTodos = useSelector(selectActiveTodos);
}

2. map で新しい配列を作る

// BAD: map は毎回新しい配列を返す
function TodoNames() {
  const names = useSelector((state) =>
    state.todos.items.map((t) => t.text)
  );
}

// GOOD
const selectTodoNames = createSelector(
  [(state) => state.todos.items],
  (todos) => todos.map((t) => t.text)
);

3. セレクタ内でオブジェクトを生成する

// BAD: 毎回新しいオブジェクトを作る
function Dashboard() {
  const stats = useSelector((state) => ({
    total: state.todos.items.length,
    completed: state.todos.items.filter((t) => t.completed).length,
  }));
}

// GOOD: createSelector を使う
const selectStats = createSelector(
  [(state) => state.todos.items],
  (todos) => ({
    total: todos.length,
    completed: todos.filter((t) => t.completed).length,
  })
);

4. コンポーネント内で createSelector を呼ぶ

// BAD: 毎レンダリングで新しいセレクタが作られる
function TodoList({ userId }) {
  const todos = useSelector(
    createSelector(
      [(state) => state.todos.items],
      (todos) => todos.filter((t) => t.userId === userId)
    )
  );
}

// GOOD: useMemo でセレクタをメモ化
function TodoList({ userId }) {
  const selectUserTodos = useMemo(
    () =>
      createSelector(
        [(state) => state.todos.items],
        (todos) => todos.filter((t) => t.userId === userId)
      ),
    [userId]
  );
  const todos = useSelector(selectUserTodos);
}
TypeScript版
import { useMemo } from 'react';
import { createSelector } from '@reduxjs/toolkit';
import { useSelector } from 'react-redux';
import type { RootState } from './store';

interface TodoListProps {
  userId: string;
}

function TodoList({ userId }: TodoListProps) {
  const selectUserTodos = useMemo(
    () =>
      createSelector(
        [(state: RootState) => state.todos.items],
        (todos) => todos.filter((t) => t.userId === userId)
      ),
    [userId]
  );
  const todos = useSelector(selectUserTodos);

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

React.memo と Redux

大きなリストをレンダリングする場合、子コンポーネントを React.memo でラップすると、不要な再レンダリングを防げます。

import { memo } from 'react';
import { useSelector } from 'react-redux';

// リストアイテムコンポーネント
const TodoItem = memo(function TodoItem({ id }) {
  // ID を使って特定の Todo を取得
  const todo = useSelector((state) =>
    state.todos.items.find((t) => t.id === id)
  );

  if (!todo) return null;

  return (
    <li style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
      {todo.text}
    </li>
  );
});

// リストコンポーネント(ID の配列だけを取得)
function TodoList() {
  const todoIds = useSelector((state) =>
    state.todos.items.map((t) => t.id)
  );

  return (
    <ul>
      {todoIds.map((id) => (
        <TodoItem key={id} id={id} />
      ))}
    </ul>
  );
}
TypeScript版
import { memo } from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from './store';

interface TodoItemProps {
  id: number;
}

const TodoItem = memo(function TodoItem({ id }: TodoItemProps) {
  const todo = useSelector((state: RootState) =>
    state.todos.items.find((t) => t.id === id)
  );

  if (!todo) return null;

  return (
    <li style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
      {todo.text}
    </li>
  );
});

function TodoList() {
  const todoIds = useSelector((state: RootState) =>
    state.todos.items.map((t) => t.id)
  );

  return (
    <ul>
      {todoIds.map((id) => (
        <TodoItem key={id} id={id} />
      ))}
    </ul>
  );
}

パターン: 親コンポーネントは ID の配列だけを取得し、子コンポーネントが個別に useSelector で自分のデータを取得する。これにより、1つの Todo が変更されても他の TodoItem は再レンダリングされません。


createEntityAdapter のセレクタ

createEntityAdapter は、正規化されたデータのための CRUD 操作とセレクタを提供します。

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

const todosAdapter = createEntityAdapter();

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosAdapter.getInitialState({
    filter: 'all',
  }),
  reducers: {
    addTodo: todosAdapter.addOne,
    updateTodo: todosAdapter.updateOne,
    removeTodo: todosAdapter.removeOne,
    setAllTodos: todosAdapter.setAll,
    setFilter: (state, action) => {
      state.filter = action.payload;
    },
  },
});

// アダプターが提供するセレクタ
export const {
  selectAll: selectAllTodos,
  selectById: selectTodoById,
  selectIds: selectTodoIds,
  selectTotal: selectTotalTodos,
  selectEntities: selectTodoEntities,
} = todosAdapter.getSelectors((state) => state.todos);

// カスタムセレクタと組み合わせ
const selectFilter = (state) => state.todos.filter;

export const selectFilteredTodos = createSelector(
  [selectAllTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);
TypeScript版
import { createEntityAdapter, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../store';

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

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

const todosAdapter = createEntityAdapter<Todo>();

interface TodosExtraState {
  filter: FilterType;
}

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosAdapter.getInitialState<TodosExtraState>({
    filter: 'all',
  }),
  reducers: {
    addTodo: todosAdapter.addOne,
    updateTodo: todosAdapter.updateOne,
    removeTodo: todosAdapter.removeOne,
    setAllTodos: todosAdapter.setAll,
    setFilter: (state, action: PayloadAction<FilterType>) => {
      state.filter = action.payload;
    },
  },
});

export const {
  selectAll: selectAllTodos,
  selectById: selectTodoById,
  selectIds: selectTodoIds,
  selectTotal: selectTotalTodos,
  selectEntities: selectTodoEntities,
} = todosAdapter.getSelectors((state: RootState) => state.todos);

const selectFilter = (state: RootState) => state.todos.filter;

export const selectFilteredTodos = createSelector(
  [selectAllTodos, selectFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);

Entity Adapter が提供するセレクタ

セレクタ 戻り値 説明
selectAll Entity[] すべてのエンティティを配列で返す
selectById Entity | undefined ID でエンティティを取得
selectIds EntityId[] すべての ID を配列で返す
selectTotal number エンティティの総数
selectEntities Record<EntityId, Entity> 正規化されたエンティティオブジェクト

実践例: フィルタ・ソート付きリスト

import { createSelector } from '@reduxjs/toolkit';

// Input selectors
const selectProducts = (state) => state.products.items;
const selectCategory = (state) => state.products.selectedCategory;
const selectSortBy = (state) => state.products.sortBy;
const selectPriceRange = (state) => state.products.priceRange;

// Step 1: カテゴリフィルタ
const selectCategoryFiltered = createSelector(
  [selectProducts, selectCategory],
  (products, category) => {
    if (category === 'all') return products;
    return products.filter((p) => p.category === category);
  }
);

// Step 2: 価格フィルタ
const selectPriceFiltered = createSelector(
  [selectCategoryFiltered, selectPriceRange],
  (products, { min, max }) => {
    return products.filter((p) => p.price >= min && p.price <= max);
  }
);

// Step 3: ソート
const selectSortedProducts = createSelector(
  [selectPriceFiltered, selectSortBy],
  (products, sortBy) => {
    const sorted = [...products];
    switch (sortBy) {
      case 'price-asc':
        return sorted.sort((a, b) => a.price - b.price);
      case 'price-desc':
        return sorted.sort((a, b) => b.price - a.price);
      case 'name':
        return sorted.sort((a, b) => a.name.localeCompare(b.name));
      case 'newest':
        return sorted.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
      default:
        return sorted;
    }
  }
);

// Step 4: 統計情報
const selectProductStats = createSelector(
  [selectPriceFiltered],
  (products) => ({
    count: products.length,
    avgPrice: products.length > 0
      ? Math.round(products.reduce((sum, p) => sum + p.price, 0) / products.length)
      : 0,
    minPrice: products.length > 0
      ? Math.min(...products.map((p) => p.price))
      : 0,
    maxPrice: products.length > 0
      ? Math.max(...products.map((p) => p.price))
      : 0,
  })
);
TypeScript版
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  createdAt: string;
}

type SortBy = 'price-asc' | 'price-desc' | 'name' | 'newest';

const selectProducts = (state: RootState) => state.products.items;
const selectCategory = (state: RootState) => state.products.selectedCategory;
const selectSortBy = (state: RootState) => state.products.sortBy;
const selectPriceRange = (state: RootState) => state.products.priceRange;

const selectCategoryFiltered = createSelector(
  [selectProducts, selectCategory],
  (products, category): Product[] => {
    if (category === 'all') return products;
    return products.filter((p) => p.category === category);
  }
);

const selectPriceFiltered = createSelector(
  [selectCategoryFiltered, selectPriceRange],
  (products, { min, max }): Product[] => {
    return products.filter((p) => p.price >= min && p.price <= max);
  }
);

const selectSortedProducts = createSelector(
  [selectPriceFiltered, selectSortBy],
  (products, sortBy): Product[] => {
    const sorted = [...products];
    switch (sortBy) {
      case 'price-asc':
        return sorted.sort((a, b) => a.price - b.price);
      case 'price-desc':
        return sorted.sort((a, b) => b.price - a.price);
      case 'name':
        return sorted.sort((a, b) => a.name.localeCompare(b.name));
      case 'newest':
        return sorted.sort(
          (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
        );
      default:
        return sorted;
    }
  }
);

interface ProductStats {
  count: number;
  avgPrice: number;
  minPrice: number;
  maxPrice: number;
}

const selectProductStats = createSelector(
  [selectPriceFiltered],
  (products): ProductStats => ({
    count: products.length,
    avgPrice: products.length > 0
      ? Math.round(products.reduce((sum, p) => sum + p.price, 0) / products.length)
      : 0,
    minPrice: products.length > 0
      ? Math.min(...products.map((p) => p.price))
      : 0,
    maxPrice: products.length > 0
      ? Math.max(...products.map((p) => p.price))
      : 0,
  })
);

実践例: ダッシュボードの派生統計

import { createSelector } from '@reduxjs/toolkit';

const selectOrders = (state) => state.orders.items;
const selectUsers = (state) => state.users.entities;

// 月別売上
const selectMonthlySales = createSelector(
  [selectOrders],
  (orders) => {
    const monthly = {};
    orders.forEach((order) => {
      const month = order.date.slice(0, 7); // "2025-01"
      monthly[month] = (monthly[month] || 0) + order.total;
    });
    return Object.entries(monthly)
      .map(([month, total]) => ({ month, total }))
      .sort((a, b) => a.month.localeCompare(b.month));
  }
);

// トップ顧客
const selectTopCustomers = createSelector(
  [selectOrders, selectUsers],
  (orders, users) => {
    const spending = {};
    orders.forEach((order) => {
      spending[order.userId] = (spending[order.userId] || 0) + order.total;
    });

    return Object.entries(spending)
      .map(([userId, total]) => ({
        user: users[userId],
        totalSpent: total,
      }))
      .sort((a, b) => b.totalSpent - a.totalSpent)
      .slice(0, 10);
  }
);

// ダッシュボード全体のサマリー
const selectDashboardSummary = createSelector(
  [selectOrders, selectMonthlySales, selectTopCustomers],
  (orders, monthlySales, topCustomers) => ({
    totalRevenue: orders.reduce((sum, o) => sum + o.total, 0),
    orderCount: orders.length,
    averageOrderValue: orders.length > 0
      ? Math.round(orders.reduce((sum, o) => sum + o.total, 0) / orders.length)
      : 0,
    monthlySales,
    topCustomers,
  })
);

パフォーマンスデバッグ

Redux DevTools

Redux DevTools の「Diff」タブで、各アクションが state のどの部分を変更したかを確認できます。不要な状態更新がないかチェックしましょう。

React DevTools Profiler

  1. React DevTools の Profiler タブを開く
  2. SettingsHighlight updates when components render を有効にする
  3. 操作を実行して、どのコンポーネントが再レンダリングされるか確認する

セレクタの再計算をチェック

// development 用: セレクタの再計算回数を確認
const selectFilteredTodos = createSelector(
  [selectTodos, selectFilter],
  (todos, filter) => {
    console.log('selectFilteredTodos recomputed!');
    switch (filter) {
      case 'active':
        return todos.filter((t) => !t.completed);
      case 'completed':
        return todos.filter((t) => t.completed);
      default:
        return todos;
    }
  }
);

// Reselect のデバッグ用 API
console.log(selectFilteredTodos.recomputations()); // 再計算回数
selectFilteredTodos.resetRecomputations(); // リセット

why-did-you-render ライブラリ

// setupTests.js
import React from 'react';

if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}

ベストプラクティス

プラクティス 説明
セレクタはスライスファイルに定義 state 構造とセレクタを近くに置く
派生データには createSelector 配列やオブジェクトの生成はメモ化する
インラインセレクタは単純な値のみ state.user.name のような単一値は OK
ID リストと個別取得パターン 大きなリストでは ID 配列を親、データ取得を子に分離
useMemo でパラメータ付きセレクタ props に依存するセレクタは useMemo でラップ
shallowEqual は最後の手段 まず createSelector を試す
再計算回数を計測 recomputations() でセレクタの効率を確認
state は正規化する createEntityAdapter で重複を避ける

まとめ

今日は、セレクタとパフォーマンス最適化について学びました。

概念 説明
セレクタ state からデータを取り出す関数
シンプルセレクタ state => state.slice.field の形式
createSelector 入力セレクタの結果をメモ化して再計算を防ぐ
メモ化 入力が同じなら前回の結果をキャッシュから返す
セレクタ合成 セレクタを組み合わせて複雑なデータを段階的に構築
useSelector 参照等価性(===)で再レンダリングを判断
shallowEqual オブジェクトの浅い比較で再レンダリングを最適化
createEntityAdapter 正規化データ用の CRUD 操作とセレクタを提供

重要なポイント:

  1. セレクタは state 構造の抽象化レイヤーとして機能する
  2. filter(), map() など新しい参照を作る操作は createSelector でメモ化する
  3. useSelector に新しいオブジェクトや配列を返すインラインセレクタを渡さない
  4. 大きなリストでは「ID リスト + 個別取得」パターンを使う
  5. パフォーマンス問題は計測してから最適化する

練習問題

問題 1: メモ化セレクタの作成

以下の要件を満たすセレクタを createSelector で作成してください:

  • state.users.items からアクティブなユーザーだけをフィルタする
  • ユーザー名でアルファベット順にソートする
  • 合計ユーザー数とアクティブユーザー数を含む統計オブジェクトを返すセレクタも作成する

問題 2: パフォーマンス改善

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

function ProductList() {
  const products = useSelector((state) =>
    state.products.items
      .filter((p) => p.inStock)
      .map((p) => ({
        ...p,
        discountedPrice: p.price * 0.9,
      }))
      .sort((a, b) => a.name.localeCompare(b.name))
  );

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}: ${p.discountedPrice}
        </li>
      ))}
    </ul>
  );
}

問題 3: Entity Adapter セレクタ

createEntityAdapter を使って「ブログ記事」の管理システムを構築してください:

  • 記事にはカテゴリと公開状態がある
  • カテゴリ別の記事一覧を返すセレクタを作成する
  • 公開済み記事の数を返すセレクタを作成する

問題 4: パラメータ付きセレクタ

ユーザー ID を受け取り、そのユーザーの注文一覧を返すセレクタを作成してください。useMemo を使って、コンポーネントの props に依存するセレクタを正しく実装しましょう。