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
- React DevTools の Profiler タブを開く
- Settings → Highlight updates when components render を有効にする
- 操作を実行して、どのコンポーネントが再レンダリングされるか確認する
セレクタの再計算をチェック
// 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 操作とセレクタを提供 |
重要なポイント:
- セレクタは state 構造の抽象化レイヤーとして機能する
filter(),map()など新しい参照を作る操作はcreateSelectorでメモ化するuseSelectorに新しいオブジェクトや配列を返すインラインセレクタを渡さない- 大きなリストでは「ID リスト + 個別取得」パターンを使う
- パフォーマンス問題は計測してから最適化する
練習問題
問題 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 に依存するセレクタを正しく実装しましょう。