はじめに
ReactやVue.jsでReduxを使う前に、Vanilla JavaScriptでReduxの本質を理解しておくと、フレームワーク特有の実装に惑わされることなく、純粋にReduxの仕組みを学ぶことができます。
この記事では、Vanilla JavaScriptを使ってReduxの基本概念から実践的な使い方、そしてRedux Toolkitによる効率化まで解説します。
Reduxとは
Reduxは、JavaScriptアプリケーションのための予測可能な状態管理ライブラリです。特に複雑なUIを持つアプリケーションにおいて、データの流れを一方向に保ち、アプリケーション全体の状態を単一のストアで管理することで、開発者が状態の変化を追跡しやすくなります。
Flux思想について
ReduxはFacebookが提唱したFluxアーキテクチャの思想を基に設計されています。Fluxは従来のMVCパターンが抱えていた「データフローの複雑化」という問題を解決するために生まれました。
Fluxの基本的な考え方は、データフローを一方向に制限することです。具体的には以下の流れでデータが流れます:
Action → Dispatcher → Store → View
↑ ↓
└────────────────────────────────┘
この単一方向のデータフローにより、アプリケーションの状態変化が予測可能になり、デバッグが容易になります。Reduxはこの思想をさらにシンプルにし、Dispatcherを排除してReducerという純粋関数で状態の更新を行う設計を採用しています。
Reduxを使用することのメリット
Reduxを導入することで得られる主なメリットは以下の通りです:
予測可能性の向上
すべての状態変化がActionとReducerを通じて行われるため、どのような操作がどのような状態変化を引き起こすかが明確になります。
デバッグの容易さ
Redux DevToolsを使用することで、アプリケーションの状態変化を時系列で追跡でき、タイムトラベルデバッグも可能になります。
テストの書きやすさ
Reducerは純粋関数であるため、同じ入力に対して常に同じ出力を返します。これによりユニットテストが非常に書きやすくなります。
状態の一元管理
アプリケーション全体の状態を単一のストアで管理するため、コンポーネント間のデータ共有が簡単になります。
実用的なサンプル - Todoリストアプリケーション
より実用的な例として、フィルタリング機能付きのTodoリストアプリケーションを実装してみましょう。このサンプルでは、複数の状態管理、配列の操作、フィルタリングという実際のアプリケーションでよく使われるパターンを扱います。
Vanilla JavaScriptでの実装
// Vanilla JavaScriptでのTodoリスト実装
let todos = [];
let filter = 'all'; // all, active, completed
let nextId = 1;
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const filterButtons = document.querySelectorAll('.filter-btn');
// Todo追加
function addTodo(text) {
if (text.trim()) {
todos.push({
id: nextId++,
text: text,
completed: false
});
todoInput.value = '';
renderTodos();
}
}
// Todo切り替え
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
renderTodos();
}
}
// Todo削除
function deleteTodo(id) {
todos = todos.filter(t => t.id !== id);
renderTodos();
}
// フィルター変更
function setFilter(newFilter) {
filter = newFilter;
updateFilterButtons();
renderTodos();
}
// フィルターボタンの更新
function updateFilterButtons() {
filterButtons.forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === filter);
});
}
// 表示更新
function renderTodos() {
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
todoList.innerHTML = filteredTodos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}">
<input type="checkbox" ${todo.completed ? 'checked' : ''}
onchange="toggleTodo(${todo.id})">
<span>${todo.text}</span>
<button onclick="deleteTodo(${todo.id})">削除</button>
</li>
`).join('');
// 統計情報の更新
document.getElementById('active-count').textContent =
todos.filter(t => !t.completed).length;
}
// イベントリスナー
todoInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addTodo(e.target.value);
}
});
filterButtons.forEach(btn => {
btn.addEventListener('click', () => setFilter(btn.dataset.filter));
});
この実装の問題点:
- グローバル変数が多く、状態管理が分散している
- 状態の変更と表示の更新が密結合している
- 状態変更の履歴を追跡できない
- テストが書きにくい(DOM操作と状態変更が混在)
Reduxを使った実装
// Reduxでのtodoリスト実装
// Action Types
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';
const SET_FILTER = 'SET_FILTER';
// Action Creators
const addTodo = (text) => ({
type: ADD_TODO,
payload: { text }
});
const toggleTodo = (id) => ({
type: TOGGLE_TODO,
payload: { id }
});
const deleteTodo = (id) => ({
type: DELETE_TODO,
payload: { id }
});
const setFilter = (filter) => ({
type: SET_FILTER,
payload: { filter }
});
// Initial State
const initialState = {
todos: [],
filter: 'all', // all, active, completed
nextId: 1
};
// Reducer
const todoReducer = (state = initialState, action) => {
switch (action.type) {
case ADD_TODO:
return {
...state,
todos: [
...state.todos,
{
id: state.nextId,
text: action.payload.text,
completed: false
}
],
nextId: state.nextId + 1
};
case TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case DELETE_TODO:
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id)
};
case SET_FILTER:
return {
...state,
filter: action.payload.filter
};
default:
return state;
}
};
// Store作成
const { createStore } = Redux;
const store = createStore(
todoReducer,
// Redux DevTools拡張機能を有効化
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
// View更新関数
const render = () => {
const state = store.getState();
// フィルタリングされたTodoを取得
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
// アクティブなTodoの数を計算
const activeCount = state.todos.filter(todo => !todo.completed).length;
// Todoリストの描画
const todoList = document.getElementById('todo-list');
todoList.innerHTML = filteredTodos.map(todo => `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input type="checkbox" ${todo.completed ? 'checked' : ''} class="todo-checkbox">
<span>${todo.text}</span>
<button class="delete-btn">削除</button>
</li>
`).join('');
// フィルターボタンの更新
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === state.filter);
});
// アクティブなTodoの数を表示
document.getElementById('active-count').textContent = activeCount;
};
// イベントリスナーの設定
document.getElementById('todo-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter' && e.target.value.trim()) {
store.dispatch(addTodo(e.target.value));
e.target.value = '';
}
});
// イベントデリゲーションを使用してTodoアイテムのイベントを処理
document.getElementById('todo-list').addEventListener('click', (e) => {
const todoItem = e.target.closest('.todo-item');
if (!todoItem) return;
const id = parseInt(todoItem.dataset.id);
if (e.target.classList.contains('todo-checkbox')) {
store.dispatch(toggleTodo(id));
} else if (e.target.classList.contains('delete-btn')) {
store.dispatch(deleteTodo(id));
}
});
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
store.dispatch(setFilter(btn.dataset.filter));
});
});
// Storeの変更を監視
store.subscribe(render);
// 初期描画
render();
// デバッグ用:コンソールで状態を確認できるようにする
window.getState = () => store.getState();
window.dispatch = store.dispatch;
Redux Toolkitでさらに簡単に!
上記のRedux実装は確かに構造化されていますが、ボイラープレートコードが多いと感じませんか?Redux Toolkitを使えば、同じ機能をもっと簡潔に実装できます。
// Redux Toolkit - たった30行で同じ機能を実現!
import { createSlice, configureStore } from '@reduxjs/toolkit';
// createSliceで全てを一度に定義
const todoSlice = createSlice({
name: 'todos',
initialState: {
todos: [],
filter: 'all',
nextId: 1
},
reducers: {
// Immerのおかげで直接変更するような書き方ができる!
addTodo: (state, action) => {
state.todos.push({
id: state.nextId++,
text: action.payload,
completed: false
});
},
toggleTodo: (state, action) => {
const todo = state.todos.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
deleteTodo: (state, action) => {
state.todos = state.todos.filter(t => t.id !== action.payload);
},
setFilter: (state, action) => {
state.filter = action.payload;
}
}
});
// Action creatorsは自動生成される!
export const { addTodo, toggleTodo, deleteTodo, setFilter } = todoSlice.actions;
// ストアの作成も1行で(DevToolsも自動設定)
const store = configureStore({
reducer: todoSlice.reducer
});
Redux Toolkitを使うことで、以下の利点があります。
コード量が60%以上削減できる
- 通常のRedux:Action Types + Action Creators + Reducer = 80行以上
- Redux Toolkit:createSlice一つで = 30行
ミュータブルな書き方ができる(Immerの魔法)
// 通常のRedux - イミュータブルな更新は複雑
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
// Redux Toolkit - 直感的に書ける!
const todo = state.todos.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
非同期処理もシンプルになる
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
// 非同期アクションの作成
const fetchTodos = createAsyncThunk(
'todos/fetch',
async () => {
const response = await fetch('/api/todos');
return response.json();
}
);
const todoSlice = createSlice({
name: 'todos',
initialState: {
todos: [],
loading: false,
error: null
},
reducers: {
// 通常のreducers
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
state.todos = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
何が改善されるのか
Redux版の実装では、以下の点が大幅に改善されています:
状態の一元管理
すべての状態が単一のストアで管理され、状態の全体像を把握しやすくなっています。
純粋な状態更新ロジック
Reducerは純粋関数として実装されており、同じActionに対して常に同じ結果を返します。これによりテストが格段に書きやすくなります。
// Reducerのテスト例
const state = {
todos: [{ id: 1, text: 'Test', completed: false }],
filter: 'all',
nextId: 2
};
const newState = todoReducer(state, toggleTodo(1));
console.assert(newState.todos[0].completed === true);
時間旅行デバッグ
Redux DevToolsを使用することで、すべてのActionの履歴を確認し、任意の時点の状態に戻ることができます。
予測可能性とメンテナンス性
どのActionがどのような状態変更を引き起こすかが明確で、新機能の追加や既存機能の修正が容易です。
Redux Toolkitなら大規模アプリケーションも簡単
Redux Toolkitのもう一つの大きなメリットは、複数の状態を管理する大規模アプリケーションの開発も簡単になることです。
import { configureStore, createSlice } from '@reduxjs/toolkit';
// Userスライス
const userSlice = createSlice({
name: 'user',
initialState: { name: '', loggedIn: false },
reducers: {
login: (state, action) => {
state.name = action.payload;
state.loggedIn = true;
},
logout: (state) => {
state.name = '';
state.loggedIn = false;
}
}
});
// Todoスライス(先ほどの例)
const todoSlice = createSlice({
name: 'todos',
initialState: { todos: [], filter: 'all', nextId: 1 },
reducers: {
// ... todoのreducers
}
});
// 複数のsliceを簡単に組み合わせられる!
const store = configureStore({
reducer: {
user: userSlice.reducer,
todos: todoSlice.reducer,
// 必要に応じてどんどん追加できる
}
});
// 状態は自動的に以下のような構造になる
// {
// user: { name: '', loggedIn: false },
// todos: { todos: [], filter: 'all', nextId: 1 }
// }
通常のReduxでは複数のReducerを管理するために追加の設定が必要ですが、Redux ToolkitならconfigureStoreに渡すだけで自動的に適切に組み合わせてくれます。各機能ごとにsliceを作成し、それぞれ独立して管理できるため、大規模なアプリケーションでも整理された状態管理が可能です。
最後に
Reduxは、Flux思想に基づいた予測可能な状態管理を実現する強力なライブラリです。単一方向のデータフローと純粋関数によるReducerにより、複雑なアプリケーションでも状態の変化を追跡しやすくなります。
Vanilla JavaScriptでReduxの基本概念を理解することは、ReactやVue.jsなどのフレームワークでReduxを使用する際の基礎となります。最初は学習曲線が急に感じるかもしれませんが、一度理解すれば、大規模なアプリケーション開発において非常に有用なツールとなるでしょう。
また、Redux Toolkitの登場により、Reduxの導入障壁は大きく下がりました。これからReduxを始める方は、基本概念を理解した上で、Redux Toolkitを積極的に活用することをお勧めします。
参考リンク
GoQSystemでは一緒に働いてくれる仲間を募集中です!
ご興味がある方は以下リンクよりご確認ください。