概要
前回、Immerを使った書き方を試してみたが、これは @reduxjs/toolkitに組み込まれているらしい。*
なので、redux.js/toolkitを使った書き方を試してみる。
対象は未来の自分、あるいはReact触り始めたくらいの人。
「Redux ExampleのTodo Listをはじめからていねいに(1)」の流れがほんとうに丁寧なのでなぞっていく。
docker上で動かしている点については前回と同様のため割愛。
環境
- Windows 10 Home
- virtualbox 6.1.2.20200116
- Vagrant 2.2.7
- ubuntu 18.04 LTS Linux ubuntu-bionic 4.15.0-76-generic #86-Ubuntu SMP Fri Jan 17 17:24:28 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
- docker-ce Docker version 19.03.5, build 633a0ea838
- docker-compose version 1.25.3, build d4d1b42b
- node 13.7
- "@reduxjs/toolkit": "^1.2.3"
- "react": "^16.12.0"
- "react-dom": "^16.12.0"
- "react-scripts": "^3.3.1"
- "typescript": "^3.7.5"
- "@types/react": "^16.9.19"
- "@types/react-dom": "^16.9.5"
- "@types/react-redux": "^7.1.7"
1. Hello World
- まずはHello Worldを表示するところから。
-
index.ts
が最上位のエンドポイント。Reactのコンポーネントをラップしている。
import React from "react";
import ReactDOM from "react-dom";
import App from "./components/App";
// reactのコンポーネントを#root以下に作成する
ReactDOM.render(<App />, document.getElementById("root"));
- 上記のファイルは以下のHTMLで読み込まれることを想定。(※DOM要素のIDがrootのものがあることに注目)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1" />
<title>TODO</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
- ReactのコンポーネントはHello Worldを表示するだけ。
import React from "react";
// React.FC は React.FunctionComponent の短縮形
// @types/reactとlib.dom.d.tsで型が衝突することがあるため、慣習としてReactの型はnamed import({FC} from 'react'みたいなやつ)を避ける
const App: React.FC = () => {
return <div> Hello World!!! </div>;
};
export default App;
2. actionCreatorで発行したactionをreducerに渡してstoreのstateを更新する
Actions
-
addTodoというactionCreatorを作る
-
actionCreatorは関数
- actionを発行するだけ
-
actionとはactionTypeとデータで構成されるオブジェクトのこと
- actionType = ADD_TODO
- データ = {id: 数字, text:文字列}
-
FSAという推奨された規約がある
- データをpayloadで渡す制約
- payloadの型はなんでもよい。
- payload: string
- payload: number
- payload: {id: number, text: string}
-
actionCreatorによって作られたactionはdispatchに渡される(後述)
import { createAction } from '@reduxjs/toolkit';
let nextTodoId = 0;
export const addTodo = createAction('ADD_TODO', (text: string) => ({ payload: { id: nextTodoId++, text } }));
Reducers
- 純粋な関数
- 現在のstateとactionを受け取り、新しいstateを返す。
- 状態を持ってはいけない(=スコープの外から変数を持ってくるのはNG。)
- 例えば、actionで使っていたnextTodoIdのようなことはダメ。
- todoというreducerの動作について
- actionTypeがADD_TODOのとき
- { id: action.id, text: action.payload }という新しいstateを返す。
- 参考: createReducer, createReducer(ts)
- createReducerの第一引数には初期状態を入れておく
- createReducerでbuilderを使うと、typescriptを使っている場合、actionの型を自動で推測してくれる
import { createReducer } from '@reduxjs/toolkit';
import { addTodo } from "../actions";
function initialState() {
return { id: 0, text: '' };
}
export const reducer = createReducer(initialState(), builder =>
builder
.addCase(addTodo, (state, action) => (action.payload))
);
Store
- アプリケーションで単一
- stateを保持する。
- configureStore関数でreducerを呼び出すことで作られる。
- 参考:configureStore
import React from "react";
import ReactDOM from "react-dom";
import { configureStore } from '@reduxjs/toolkit';
import App from "./components/App";
import reducer from './reducers';
const store = configureStore({ reducer });
ReactDOM.render(<App />, document.getElementById("root"));
流れを確認
import React from "react";
import ReactDOM from "react-dom";
import { configureStore } from '@reduxjs/toolkit';
import App from "./components/App";
import reducer from './reducers';
+ import { addTodo } from "./actions";
const store = configureStore({ reducer });
+ store.dispatch(addTodo("Hello World!"));
+ console.log(store.getState());
ReactDOM.render(<App />, document.getElementById("root"));
- action -> reducer -> storeの一連の流れ
- 実際にstateが更新されるのを見る
- addTodo('Hello World!')
- actionCreator
- {payload: { id: 0, text: 'Hello World' }}というactionを作成
- store.dispatch(addTodo("Hello World!"));
- dispatch関数にactionを渡す
- todo reducerの引数にstateとactionが渡される
- state = 現在のstate(初期値の{id:0, text ''})
- action = {payload: { id: 0, text: 'Hello World' }},
- todo reducerでは、新しいstate = { id: 0, text: 'Hello World' }を返す。
- store.getState()
- storeが保持しているstateを取得
chrome developer toolで確認。
3. storeで保持したstateをViewで表示する
Todo Listの作成
reducerを書き換える
todoのreducerを別ファイルに外だし
- stateでtodoを保持することはできた
- このままでは1つのtodoしか保持できない
- 複数のtodoを保持できるよう拡張
- 今後のことを考えtodosのreducersを別ファイル(index.js -> todos.js)にする。
- exampleではtodos以外のreducerを使うため
- 1つしか使わない場合は別ファイル不要
-
state.push
で、一見stateを直接書き換えているように見えるが実は新しいstateを作って返している- 内部的にimmerを使っているため
- 参考:Direct State Mutation
- immerを使いたい場合、returnはしないようにすること
- たとえば、
(state, action) => state.push(action.payload)
の書き方だと暗黙的なreturnになり、immerを使用しない - 「returnする場合、従来のreducerを使用」「returnしない場合、immerを使用」という動きっぽい。
- たとえば、
- 内部的にimmerを使っているため
export interface ITodo {
id: number;
text: string;
}
import { createReducer } from '@reduxjs/toolkit';
import { addTodo } from '../actions';
import { Todo } from '../@types';
function initialState(): ITodo [] {
return [];
}
const todos = createReducer(initialState(), builder =>
builder
.addCase(addTodo, (state, action) => {
state.push(action.payload);
})
);
export default todos;
reducer/index.tsで外だししたreducerを統合
-
todo
のreducer
を別ファイルにしたため、reducers/index.ts
は各reducer
を統合する役割にする - 「
configureStore
のreducer
プロパティ」に、「各reducerをプロパティにしたオブジェクト」を入れると、内部的にcombineReducersの挙動をする-
createStore(combineReducers(todos)) = configureStore(reducer:{todos})
-
- combineReducers
- 複数のreducersを結合する
- reducerが結合された場合のstateの挙動も変化する
- storeで作成されるstateの違い
- createStore(todos)
- state = [todo1, todo2]
- createStore(combineReducers(todos))
- state = {todos = [todo1, todo2]}
- createStore(todos)
import todos from "./todos";
const rootReducer = { todos };
export default rootReducer;
ToDoコンポーネントとToDoListコンポーネントを作る
Todoコンポーネント
- propとして渡されてきたtextを表示する
import React from "react";
const Todo: React.FC<{ text: string }> = ({ text }) => {
return <li>{text} </li>;
};
export default Todo;
TodoListコンポーネント
- todosを取得する
- todosの各要素をTodoコンポーネントに渡す
- 配列としてコンポーネントを複数生成するときkeyが必要になる
- {...todo}はtodoのすべての要素
- id={todo.id} text={todo.text}と同じ
- 参考:スプレッド構文
- {...todo}はtodoのすべての要素
- 配列としてコンポーネントを複数生成するときkeyが必要になる
import React from "react";
import Todo from "./Todo";
import { useTodoItems } from '../reducers/todos';
const TodoList: React.FC = props => {
const todos = useTodoItems();
return (
<ul>
{todos.map(todo => (
<Todo key={todo.id} {...todo} />
))}
</ul>
);
};
export default TodoList;
- reducerと一緒にselectorも定義してしまう
import { createReducer } from '@reduxjs/toolkit';
+ import { useSelector } from "react-redux";
import { addTodo } from '../actions';
import { ITodo } from '../@types';
function initialState(): ITodo[] {
return [];
}
const todos = createReducer(initialState(), builder =>
builder
.addCase(addTodo, (state, action) => {
state.push(action.payload);
})
);
export default todos;
+ export const useTodoItems = () => {
+ return useSelector((state: { todos: ReturnType<typeof todos> }) => state.todos);
+ }
Providerの設定
- 作成したstoreをコンポーネントで使用するため、一番大きい単位を
<Provider>
で囲む -
Provider
の属性に作成したstoreを指定する
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { configureStore } from '@reduxjs/toolkit';
import App from "./components/App";
import reducer from './reducers';
import { addTodo } from "./actions";
const store = configureStore({ reducer });
store.dispatch(addTodo("Hello World!"));
console.log(store.getState());
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root"));
ブラウザで確認
4. フォームからtodoを追加
- フォームからtodoを追加する
- (参考:Redux の記述量多すぎなので、 Redux の公式ツールでとことん楽をする。 ( Redux Toolkit))
- buttonをクリック
- dispatch(addTodo(input))でinputに入ってる値をtodoに追加
- inputはReact hookの仕組みを使用
- (参考:React hook)
- 初期値は""
-
onChange
イベントでsetInput(e.target.value)
からinput
は更新される
import React, { useState } from "react";
import { useDispatch } from "react-redux";
import { addTodo } from "../actions";
const AddTodo: React.FC = props => {
// local state
const [input, setInput] = useState("");
// dispatch を用意
const dispatch = useDispatch();
// ハンドラーを用意。タスクを追加したらテキストエリアのクリア
const clickHandler = () => {
if (input !== "") {
dispatch(addTodo(input));
setInput("");
}
}
return (
<div>
<input
type="text"
onChange={e => {
setInput(e.target.value);
}}
value={input}
/>
<button onClick={clickHandler}>add</button>
</div>
);
};
export default AddTodo;
- App.tsxに
AddTodo
コンポーネントを追加
import React from "react";
import AddTodo from './AddTodo';
import ToddoList from './TodoList';
const App: React.FC = () => {
return <div>
<AddTodo />
<ToddoList />
</div>;
};
export default App;
ブラウザで確認
5. 完了・未完了を表すcompletedによってスタイルを変える
ここからは、Todoの完了・未完了を切り替える「Toggle Todo」の機能を作っていく。
todoにcompleted要素を追加して、とりあえず取り消し線を表示する
- completed要素を追加
- todoごとに完了・未完了を区別するため
- デフォルトはfalse
- todo作成時は、未完了のため
export interface ITodo {
id: number;
text: string;
+ completed: boolean;
}
const todos = createReducer(initialState(), builder =>
builder
.addCase(addTodo, (state, action) => {
- state.push(action.payload);
+ state.push(
+ {
+ completed: false,
+ ...action.payload,
+ });
})
);
Todoコンポーネントを修正
- completedがtrueだったらtextDecorationをline-throughにする
import React from "react";
const Todo: React.FC<{ completed: boolean, text: string, }> = ({ completed, text }) => {
return <li style={{ textDecoration: completed ? 'line-through' : 'none' }}>
{text}
</li>;
};
export default Todo;
- 一時的にreducers/todos.jsのcompleted: falseをtrueにして動作確認
actionCreatorからcompleted要素を操作する
- action経由で取り消し線のON/OFFを行う
actionCreatorの追加
- actionCreatorで必要なのは、todoのidだけ
import { createAction } from '@reduxjs/toolkit';
let nextTodoId = 0;
export const addTodo = createAction('ADD_TODO', (text: string) => ({ payload: { id: nextTodoId++, text } }));
+ export const toggleTodo = createAction('TOGGLE_TODO', (id: number) => ({ payload: id }));
reducerの追加
- payloadで送られたidと同じidの要素のcompletedを反転させる
- ここでは
.map
で要素を作り直しているため、immerは使用していない
const todos = createReducer(initialState(), builder =>
builder
.addCase(addTodo, (state, action) => {
state.push(
{
completed: true,
...action.payload,
});
})
+ .addCase(toggleTodo, (state, { payload }) => state.map(todo => ({ ...todo, completed: todo.id === payload ? !todo.completed : todo.completed })))
);
-
dispatch
を呼んで、true/falseが変更されるのを確かめてみる
import reducer from './reducers';
-import { addTodo } from "./actions";
+import { addTodo, toggleTodo } from "./actions";
const store = configureStore({ reducer });
store.dispatch(addTodo("Hello World!"));
console.log(store.getState());
+store.dispatch(toggleTodo(0));
6. クリックしてcompletedの値を変える
子コンポーネントの修正
- onClickイベントで親から渡されたtoggle関数を実行する
import React from "react";
const Todo: React.FC<{ completed: boolean, text: string, toggle: () => void }> = ({ completed, text, toggle }) => {
return <li style={{ textDecoration: completed ? 'line-through' : 'none' }} onClick={toggle}>
{text}
</li >;
};
export default Todo;
親コンポーネントの修正
import React from "react";
import { useDispatch } from "react-redux";
import { toggleTodo } from "../actions";
import Todo from "./Todo";
import { useTodoItems } from '../reducers/todos';
const TodoList: React.FC = props => {
const todos = useTodoItems();
const dispatch = useDispatch();
return (
<ul>
{todos.map(todo => <Todo key={todo.id} {...todo} toggle={() => dispatch(toggleTodo(todo.id))} />)}
</ul>
);
};
export default TodoList;
7 Filter Todo
- 表示するTodo Listを完了または未完了のTodoだけにする「Filter Todo」機能を作る
- 以下の3つのフィルターによって表示を変更する
- SHOW_ALL: 全部表示
- SHOW_COMPLETED: 完了しているtodoのみ
- SHOW_ACTIVE: 完了していないtodoのみ
- 開発順は以下とする
- actionCreatorとreducerでフィルターの値をstore(state)に格納
- フィルターの値によってviewを変更(手動でフィルターを操作して動作確認)
- リンクをクリックしてフィルターを操作してviewを変更
7-1 actionCreatorとreducerでフィルターの値をstore(state)に格納
moduleの作成
- reducerとactionsを同時につくる
export type FilterType = 'SHOW_ALL' | 'SHOW_COMPLETED' | 'SHOW_ACTIVE';
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { useSelector } from "react-redux";
import { FilterType } from '../@types';
function initialState(): FilterType {
return 'SHOW_ALL';
}
// actions と reducers の定義
const visibilityFilterModules = createSlice({
name: "visibilityFilter",
initialState: initialState(),
reducers: {
visibilityFilter: (state, action: PayloadAction<FilterType>) => action.payload,
}
});
export default visibilityFilterModules;
import todos from "./todos";
import visibleFilterModule from '../module/visibilityFilterModules';
const rootReducer = { todos, visibleFilter: visibleFilterModule.reducer };
export default rootReducer;
手動で動作確認
// 省略
import visibilityFilterModules from './module/visibilityFilterModules';
const store = configureStore({ reducer });
console.log(store.getState()) // => Object {todos: Array[0], visibilityFilter: "SHOW_ALL"}
store.dispatch(visibilityFilterModules.actions.visibilityFilter('SHOW_COMPLETED'))
console.log(store.getState()) // => Object {todos: Array[0], visibilityFilter: "SHOW_COMPLETED"}
// 省略
7-2. フィルターの値によってviewを変更
// 省略
export const useVisibleFilter = () => {
return useSelector((state: { visibleFilter: ReturnType<typeof visibilityFilterModules.reducer> }) => state.visibleFilter);
}
import React from "react";
import { useDispatch } from "react-redux";
import { toggleTodo } from "../actions";
import Todo from "./Todo";
import { useTodoItems } from '../reducers/todos';
import { useVisibleFilter } from '../module/visibilityFilterModules';
const TodoList: React.FC = props => {
let todos = useTodoItems();
const dispatch = useDispatch();
const filter = useVisibleFilter();
switch (filter) {
case 'SHOW_ALL':
break;
case 'SHOW_COMPLETED':
todos = todos.filter((t) => t.completed);
break;
case 'SHOW_ACTIVE':
todos = todos.filter((t) => !t.completed);
}
return (
<ul>
{todos.map(todo => <Todo key={todo.id} {...todo} toggle={() => dispatch(toggleTodo(todo.id))} />)}
</ul>
);
};
export default TodoList;
7-3. リンクをクリックしてフィルターを操作してviewを変更
- リンクを表示させる
- dispatch(setVisibilityFilter())を呼び出す
- クリックしたときにonClickを呼ぶ
import React from "react";
const Link: React.FC<{ children: any, onClick: () => void }> = ({ children, onClick }) => {
// eslint-disable-next-line jsx-a11y/anchor-is-valid
return <a href="#" onClick={(e) => { e.preventDefault(); onClick(); }}>{children}</a>;
};
export default Link;
import React from "react";
import { useDispatch } from "react-redux";
import Link from './Link';
import visibilityFilterModules from '../module/visibilityFilterModules';
const Footer: React.FC = () => {
const dispatch = useDispatch();
const visibilityFilter = visibilityFilterModules.actions.visibilityFilter;
return <p>
Show:
{" "}
<Link onClick={() => dispatch(visibilityFilter('SHOW_ALL'))}>
All
</Link>
{", "}
<Link onClick={() => dispatch(visibilityFilter('SHOW_ACTIVE'))}>
Active
</Link>
{", "}
<Link onClick={() => dispatch(visibilityFilter('SHOW_COMPLETED'))}>
Completed
</Link>
</p>;
};
export default Footer;
import React from "react";
import AddTodo from './AddTodo';
import ToddoList from './TodoList';
+ import Footer from './Footer';
const App: React.FC = () => {
return <div>
<AddTodo />
<ToddoList />
+ <Footer />
</div>;
};
export default App;
activeな状態のリンクを押せないようにする
import React from "react";
const Link: React.FC<{ active: boolean, children: any, onClick: () => void }> = ({ active, children, onClick }) => {
if (active) {
return <span>{children}</span>;
}
// eslint-disable-next-line jsx-a11y/anchor-is-valid
return <a href="#" onClick={(e) => { e.preventDefault(); onClick(); }}>{children}</a>;
};
export default Link;
import React from "react";
import { useDispatch } from "react-redux";
import Link from './Link';
import visibilityFilterModules, { useVisibleFilter } from '../module/visibilityFilterModules';
const Footer: React.FC = () => {
const dispatch = useDispatch();
const visibilityFilter = visibilityFilterModules.actions.visibilityFilter;
const filter = useVisibleFilter();
return <p>
Show:
{" "}
<Link onClick={() => dispatch(visibilityFilter('SHOW_ALL'))} active={filter === 'SHOW_ALL'}>
All
</Link>
{", "}
<Link onClick={() => dispatch(visibilityFilter('SHOW_ACTIVE'))} active={filter === 'SHOW_ACTIVE'}>
Active
</Link>
{", "}
<Link onClick={() => dispatch(visibilityFilter('SHOW_COMPLETED'))} active={filter === 'SHOW_COMPLETED'}>
Completed
</Link>
</p>;
};
export default Footer;
これでactiveな状態のリンクを押せないようになり、「Filter Todo」機能が完成。
備考
メモ化によるパフォーマンス改善について
- これくらいの小さいものなら不要そう。
- 子コンポーネントに dispatch を渡す場合、useCallbackを利用して、メモ化する
- 親の再レンダリングによって子コンポーネントが不必要に再レンダリングされることを回避するため
子コンポーネントの修正
- onClickイベントで親から渡されたtoggle関数を実行する
-
React.memo
でpropsが変わらなかったときには変更されないようにする。
import React from "react";
const Todo: React.FC<{ completed: boolean, text: string, id: number, toggle: (id: number) => void }> = React.memo(({ completed, text, id, toggle }) => {
return <li style={{ textDecoration: completed ? 'line-through' : 'none' }} onClick={() => toggle(id)}>
{text}
</li >;
});
export default Todo;
親コンポーネントの修正
- useCallbackでメモ化する
- dispatchでActionを呼び出す関数を子コンポーネントに渡す
- ここで
useCallback
を行うことにより、関数は不変なものとして子コンポーネントに渡される- 使わなかった場合、アロー関数は毎回違うオブジェクトと認識されてしまう。
- せっかく、
React.memo
でpropsが変わらなかったら再レンダリングしないとしている意味がなくなる -
React.memo
やuseCallback
はバグのもとになりやすいので、パフォーマンス上問題なければ使わないほうが無難かも
- せっかく、
- 使わなかった場合、アロー関数は毎回違うオブジェクトと認識されてしまう。
import React, { useCallback } from "react";
import { useDispatch } from "react-redux";
import { toggleTodo } from "../actions";
import Todo from "./Todo";
import { useTodoItems } from '../reducers/todos';
const TodoList: React.FC = props => {
const todos = useTodoItems();
const dispatch = useDispatch();
const clickHandler = useCallback((id: number) => dispatch(toggleTodo(id)), [dispatch]);
return (
<ul>
{todos.map(todo => <Todo key={todo.id} {...todo} toggle={clickHandler} />)}
</ul>
);
};
export default TodoList;
効率化?
- memoを使った場合と、使わない場合で、dispatchの動作は、一見、変わっていないように見えた
- DOMでの確認なので、内部的には違うのかも
- liがすべて書き換わるようだったら、問題だけど、これならパフォーマンスが問題になるまで対応不要かも。
環境詳細
バージョン
OS
- Windows 10 Home
アプリケーション
PS C:\WINDOWS\system32> clist -l | Select-String "virtualbox"
virtualbox 6.1.2.20200116
PS C:\WINDOWS\system32> vagrant -v
Vagrant 2.2.7
仮想環境
vagrant@ubuntu-bionic[]:~$ uname -a
Linux ubuntu-bionic 4.15.0-76-generic #86-Ubuntu SMP Fri Jan 17 17:24:28 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
vagrant@ubuntu-bionic[]:~$ docker -v
Docker version 19.03.5, build 633a0ea838
vagrant@ubuntu-bionic[]:~$ docker-compose --version
docker-compose version 1.25.3, build d4d1b42b
インストールしたパッケージ
{
"name": "my-react",
"version": "0.1.0",
"private": true,
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "^1.2.3",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.1.3",
"redux": "^4.0.5"
},
"devDependencies": {
"@types/jest": "^25.1.1",
"@types/node": "^13.5.3",
"@types/react": "^16.9.19",
"@types/react-dom": "^16.9.5",
"@types/react-redux": "^7.1.7",
"@types/redux": "^3.6.0",
"react-scripts": "^3.3.1",
"typescript": "^3.7.5"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
参考
Redux の記述量多すぎなので、 Redux の公式ツールでとことん楽をする。 ( Redux Toolkit)
フック API リファレンス
Redux ExampleのTodo ListをはじめからていねいにをもういちどTypescriptとImmerで
redux-toolkit
Redux Starter KitでHooksとReduxを使いこなそう
ハンバーガー屋で隣の女子高生が語るReact 第3話 Hooks
Redux ExampleのTodo Listをはじめからていねいに(1)
React HooksのuseCallbackを正しく理解する
最近Reactを始めた人向けのReact Hooks入門
雰囲気で使わない React hooks の useCallback/useMemo
本当は怖いReact.memo
Reduxの個人的チュートリアル(redux-toolkitあり)