2023/07/23 更新、 Redux Hook APIに対応
React Reduxをキチンと理解したいという想いを胸にまとめてみました。
まずReduxのコア機能について、そのエッセンスを解説します。
次にReduxをReactに組み込んで使うための、React Reduxというライブラリを解説します。これはReduxの公式サイトにあるTodosアプリを使って行います。
1.Reduxのコア機能
1-1.Reduxのインプリメント
実際にReduxのソースコードをみることで、内部でやっていることを調べてみます。
https://github.com/leonciocaminha/fullstack-react-code/blob/master/redux/counter/complete/reducer-w-store-v1.js
以下のコードが、オリジナルのcreateStoreのソースの抜粋になります。Reduxの実装がとてもシンプルなのがわかります。一口で言えば、storeとは内部状態(currentState)をカプセル化したクロージャで、currentStateにアクセスするgetStateとdispatchという2つのアクセス関数を提供してくれます。getStateで現在の状態を取得し、dispatchにactionを指定することで状態を更新します。
// state をカプセル化するクロージャを作り出す
export default function createStore(reducer, preloadedState, enhancer) {
let currentReducer = reducer // reducerを設定
let currentState = preloadedState // stateの初期値を設定
function getState() { // stateのアクセス関数
return currentState
}
function dispatch(action) { // actionとreduceによるstateの更新関数
currentState = currentReducer(currentState, action)
return action
}
return { // createStoreはdispachとgetStateを返す
dispatch,
getState
}
}
1-2.Reduxをテストしてみる
テスト環境を作ります。create-react-app で作成した環境に、Reduxのテストコードを挿入します。この時点では特にReactと連携はしていません。
npx create-react-app redux-test1
cd redux-test1
npm install --save redux
create-react-appで生成したindex.jsにテストコードを挿入します。テストコード以外は無視してください。
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
//----------------------テストコード開始
import { createStore } from 'redux'
// reducer = counterの定義
// reducer = pure function with (state, action) => state signature.
function counter(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
// クロージャ store を作り出す
let store = createStore(counter)
// actionをdispatchすることでstate(内部のプライベート変数)を更新
store.dispatch({ type: 'INCREMENT' })
// getStateでstate(内部のプライベート変数)にアクセス
console.log('***' + store.getState()) // 1
store.dispatch({ type: 'INCREMENT' })
console.log('***' + store.getState()) // 2
store.dispatch({ type: 'INCREMENT' })
console.log('***' + store.getState()) // 3
store.dispatch({ type: 'DECREMENT' })
console.log('***' + store.getState()) // 2
//----------------------テストコード終了
ReactDOM.render(<App />, document.getElementById('root'));
npm startで起動して、ブラウザで http://localhost:3000/ にアクセスしコンソールへの出力を確認します。
storeが思い通りに動作していることが確認できました。
2.React Redux
次にReactとReduxの関係を見ていきます。React Reduxを使います。
React Reduxの公式サイト
https://react-redux.js.org/
説明にはreduxのgithubサイトからtodos exampleを使います。
https://github.com/reduxjs/redux/tree/master/examples/todos (ソース)
https://redux.js.org/basics/usagewithreact (ドキュメント)
2-1.Example環境構築
npx create-react-app todos
cd todos
package.jsonとsrcを上書きコピーして、npm installし、アプリを起動します。
npm install
npm start
2-2.Exampleソース
まず Redux のコア機能である、Reducer と Actions の関数を定義します。
そのあとで Redux を React に組み込む方法を見たいと思います。
2-2-1.Reducer - Reduxコア機能
ここでは combineReducers を使って、2つの Reducer 、todos と visibilityFilter、をひとつの Reducer に結合します。それを createStore の引数とします。
結合された reducer はおのおのの子供 reducer を呼び出し、それぞれの結果を集めて一つの state オブジェクトにします。下記のコードで言えば、それぞれの子供 reducer は、state.todos や state.visibilityFilter などのように参照できます。
import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'
export default combineReducers({
todos, // *** state.todos でアクセス
visibilityFilter // *** state.visibilityFilter でアクセス
})
以下は todos reducer です。
// *** todosの初期状態は、state = []
const todos = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO': // *** todo を追加
return [
...state, // *** スプレッド構文、state に追加
{
id: action.id,
text: action.text,
completed: false
}
]
case 'TOGGLE_TODO': // *** todo.completed をトグル
return state.map(todo =>
(todo.id === action.id)
? {...todo, completed: !todo.completed} // *** スプレッド構文、todo の completed を変更
: todo
)
default:
return state
}
}
export default todos
ここで使われているスプレッド構文は以下のようなものです。
state=[1,2,3]
res=[...state,4,5]
console.log(res);
// expected output: [1, 2, 3, 4, 5]
todo={a:1,b:2,c:3}
todo2={...todo,b:4}
console.log(todo2)
// expected output: {a: 1, b: 4, c: 3}
補足ですが、Redux の state は 変更不可(immutable) である必要があります。通常 immutable を保ったまま state を更新するためには、 Object.assign() が使われることが多いのですが、今回のように スプレッド構文 (object spread syntax) を使うこともできます。但しどちらも シャローコピー である点に注意してください。
また最近では、この目的のためにFacebook製のImmutable.jsを推奨する記事が多くみられます。
visibilityFilter reducer です。
import { VisibilityFilters } from '../actions'
// *** visibilityFilterの初期状態は、state = VisibilityFilters.SHOW_ALL
const visibilityFilter = (state = VisibilityFilters.SHOW_ALL, action) => {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.filter
default:
return state
}
}
export default visibilityFilter
2-2-2.Action Creator - Reduxコア機能
Action Creator は小さなアロー関数で、Action リテラルオブジェクトを返してくれます。dispatch は Action リテラルオブジェクトを引数にとりますので、 Action Creator 関数(の呼び出し実行)を引数の中に書くことができます。文法的な注意事項としては、アロー関数がオブジェクトを返すだけなら( {height: 171, age: 18} )のように小カッコ()でくくる必要があることに注意してください。構文の{ }とオブジェクトの{ }の区別がつかなくなるからです。
let nextTodoId = 0
export const addTodo = text => ({ // *** (1) ADD_TODO action
type: 'ADD_TODO',
id: nextTodoId++,
text
})
export const toggleTodo = id => ({ // *** (2) TOGGLE_TODO action
type: 'TOGGLE_TODO',
id
})
export const setVisibilityFilter = filter => ({ // *** (3) SET_VISIBILITY_FILTER action
type: 'SET_VISIBILITY_FILTER',
filter // *** 'SHOW_ALL','SHOW_COMPLETED','SHOW_ACTIVE'
})
export const VisibilityFilters = {
SHOW_ALL: 'SHOW_ALL',
SHOW_COMPLETED: 'SHOW_COMPLETED',
SHOW_ACTIVE: 'SHOW_ACTIVE'
}
(補足)ファイル先頭の、 let nextTodoId = 0、はこのファイルブロック内でのみアクセス可能なグローバル変数の宣言になります。
(注意)Store分割時にactionがどちらに適用されるのか?
現状、Store は state.todos と state.visibilityFilter の2つに分割されています。しかし action の定義を見ると、その action がどちらの state に適用されるかは指示されていませんし、Reducer においてaction.type で割り振りされているだけです。つまり action は Reducer の action.type のみを見て実行されます。 例えば、action.type='ADD_TODO' の action を dispatch した時、action.type='ADD_TODO' はsrc/reducers/todos.js の中で定義されているので、この定義が実行されて終了します。しかしaction.type='ADD_TODO' の定義が、src/reducers/visibilityFilter.js の中でも定義されていれば、こちらの定義も実行されます。つまり2つの Reducer をスキャンして両方に action.type='ADD_TODO' の定義があれば、両方とも実行されます。
(補足)Reducer(Store)とActionの役割分担
現実的な問題として、Reducer と Action の役割分担があります。ここでは指針として、以下のブログを紹介しておきます。今回の Todo アプリのコーディングはこの指針に沿ったものとは言えませんが、こういった指針は規模が大きくなればなるほど重要といえるでしょう。
Flux の Action と Store をちゃんと分ける話
https://devpixiv.hatenablog.com/entry/2015/12/19/113746
Action には View から受け取った Payload を加工(クエリを構築して API と通信したり)して、
適切なモデル型の Entity を構築する責務を持たせます。
データ操作の権限を Action に集約することで、View が受け取った Entity の過不足に関する
実装の不備を追いやすくします。
Store には Action からやってきた Entity を振り分けてコンテナへ格納し、
View へ Entity を供給する責務を持たせます。
Store は機能の増加に伴って容易に複雑化するため、ここにデータを Entity へ加工する処理
などを実装してしまうと見通しが悪くなるので注意します。
※ Payload は View が Action に対して発行するオブジェクトで
『あるアクションのうちどれを実行するか、id は何か』といった断片的な情報を含むものを指します。
Entity は、例えば Item の Entity と言えば、完全な Item 情報を持ったものを指します
つまり、Store で Entity の操作はしない、というポリシーです
2-2-3.React Reduxの概略
これまではReduxのコア機能に関する部分を実装しました。
ここでは React に Redux を組み込んで利用するための React Redux ライブラリの説明を行います。
まずは Container Component と Presentational Component の説明です。これは Redux を React に組み込んで使うためのライブラリ React Redux の Component 作成のガイドラインです。
https://redux.js.org/basics/usagewithreact (ドキュメント)
それぞれの Components を機能的に2つに分けて、Container Components と Presentational Componentsと呼び、前者に 「ロジックの部分」 を、後者に 「見た目の部分」 を任せます。
データの流れ的には、Container Componentsでデータを獲得し、Presentational componentsに流し込み表示させます。例えば Redux からデータを獲得するのは Container です。Presentational は Container から渡されたデータを表示するだけで Redux のことは何も知りません。
ソースコードで言えば、Presentational components は src/components で、Container Components は src/container になります。
技術的には、Container component は、Redux state tree を読むために store.subscribe() を利用している React component に過ぎません。それは props を通して Presentational component に受け渡され render されます。
最新の Container component は Redux Hooks を使う!(追記 2023/07/23)
2019年に Redux Hooks API がサポートされ、従来の connect() 関数 や mapStateToProps、mapDispatchToProps は推奨されなくなったようです。今回は Container component の Redux Hooks 版ソースコードを追加しました。Container 以外のソースコードは変更の必要が無く、本記事の骨子は変わりがないのでそのまま残してあります。
Hooks - React Redux
Redux Hooks によるラクラク dispatch & states - Qiita
具体的に言えば、Container component は React Redux ライブラリの connect() 関数 を使ってこれを成し遂げます(明示的にstore.subscribe()を使う必要はありません)。connect() は、以下のように mapStateToProps(state) と mapDispatchToProps(dispatch) とあわせて使われます。
connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)
connect 関数 の観点から、上の TodoList (Presentational component) は connected component と呼ばれたりします。
(1)mapStateToProps
mapStateToProps は、store.state から、下位の connected component が必要とするデータをのみを抜粋する役割を持ちます。
- state が変わる度に呼び出される。(暗に subscribe している)
- 全 state が与えられるので、必要な分の部分オブジェクトを返すようにすべき。
Connect: Extracting Data with mapStateToProps - React Redux公式サイト
(2)mapDispatchToProps
mapDispatchToProps は store.dispatch を使うのに利用されます。React Reduxにおいて store.dispatch は直接使うことはできません。 以下の2つの方法で間接的に使います。
- デフォルトで connected component は props.dispatch を受け取るので、これを使って自分で action を dispatch することができる。
- mapDispatchToProps で action を dispatch する関数を定義し、それを props として connected component に渡す。この場合、上の props.dispatch は使えません。
Connect: Dispatching Actions with mapDispatchToProps - React Redux公式サイト
以下に両関数の引数を示します。
mapStateToProps(state, [ownProps])
mapDispatchToProps(dispatch, [ownProps])
ownProps はconnect() 関数 を利用する Container component が親から渡された props です。例えば、 mapStateToProps は state からデータを抜粋するときに、ownProps を参照して抜粋することができます。
以上が React Redux の概略です。それではこの前提知識を踏まえて Todo アプリのソースを眺めていきましょう。
2-2-4.トップ
createStore を行ってから、root component である App.js を呼び出します。
ここで特別な React Redux component である <Provider> を使います。これを root component で使えば、下位の全ての container component において store を使えるようになります。明示的に store を渡す記述は必要ありません。
import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './components/App'
import rootReducer from './reducers'
const store = createStore(rootReducer)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
以下が表示のトップ component です。
import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
const App = () => (
<div>
<AddTodo />
<VisibleTodoList />
<Footer />
</div>
)
export default App
画面の構成要素と Components の対応です。
2-2-5.Container と Presentational
(1)AddToDo
これはある意味、特殊な component です。Tod oを追加するための、ひとつの入力フィールドとボタンを持っているだけです。Container と Presentational の2つの component に分けるには小さすぎるので、ひとつの component で済ませています。ガイドはあくまでもガイドに過ぎず絶対ではありません。
import React from 'react'
import { connect } from 'react-redux'
import { addTodo } from '../actions'
// *** functional components は引数で props を受け取る
// *** connected component( = AddTodo ) には props.dispatc hが渡される
// *** 分割代入で dispatch を取り出す
const AddTodo = ({ dispatch }) => { // *** (1)
let input // *** input変数
return (
<div>
<form onSubmit={e => {
e.preventDefault()
if (!input.value.trim()) { // *** input DOM 要素の value にアクセス
return
}
dispatch(addTodo(input.value)) // *** (2) ADD_TODO action
input.value = ''
}}>
<input ref={node => input = node} /> // *** input 変数に DOM 要素を代入
<button type="submit">
Add Todo
</button>
</form>
</div>
)
}
export default connect()(AddTodo) // *** (3)
Redux Hooks
import React from 'react'
import { useDispatch } from 'react-redux'
import { addTodo } from '../actions'
const AddTodo = props => {
let input
const dispatch = useDispatch();
return (
<div>
<form onSubmit={e => {
e.preventDefault()
if (!input.value.trim()) {
return
}
dispatch(addTodo(input.value))
input.value = ''
}}>
<input ref={node => input = node} />
<button type="submit">
Add Todo
</button>
</form>
</div>
)
}
export default AddTodo
まず、以下の2行に注目します。
const AddTodo = ({ dispatch }) // ***(1)
...
export default connect()(AddTodo) // ***(3)
React Redux においては、store.dispatch に直接アクセスすることはできません。2通りの間接的な方法でアクセスします。ひとつは前述した mapDispatchToProps を通してアクセスする方法です。2つ目が今回の方法です。
デフォルトで connected component (今回は(3)行の AddTodo component に相当) は props.dispatch を渡されます。 それを (1) 行の 分割代入 で dispatch を取り出しているわけです。
Connect: Dispatching Actions with mapDispatchToProps - React Redux公式サイト
reduxjs/react-redux(react-redux/docs/api/connect.md) - github
さらに <input ref={node => input = node} /> に注目します。これは React において直接 DOM にアクセスする方法です。DOM 要素を変数 input に代入します。input.value でその DOM 要素の value にアクセスしています。
Refs and the DOM: React公式ドキュメント
また、ここで使われている 分割代入 (Destructuring assignment) 構文 は以下のようなものです。オブジェクトと同じプロパティ名を変数とすることでマッチさせています。
const xxx = {car:'Honda', age: '16'}
const {age, car} = xxx
console.log(age)
// expected output: 16
console.log(car)
// expected output: Honda
Reducer と Action の復習
(2) の dispatch( addTodo( input.value ) ) について、Reducer と Action の定義を復習をしたいと思います。dispatch の引数には addTodo( input.value ) の実行結果である Action リテラルオブジェクトが渡されます。
// --- Reducer
const todos = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO': // *** todoを追加
return [
...state, // *** 分割代入、stateに追加
{
id: action.id,
text: action.text,
completed: false
}
]
---
}
// --- Action
export const addTodo = text => ({ // *** (1) ADD_TODO action
type: 'ADD_TODO',
id: nextTodoId++,
text
})
(2)VisibleTodoList
VisibleTodoList component は Container であり、子供の TodoList component (Presentational) が表示に使う Redux store のデータの準備をし、受け渡す役割を果たします。
つまり VisibleTodoList (Container) は state.todos を、現在の state.visibilityFilter でフィルタしたものと、TOGGLE_TODO action に紐づけた toggleTodo ハンドラを、下位の TodoList (Presentational) に渡します。
import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { VisibilityFilters } from '../actions'
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case VisibilityFilters.SHOW_ALL:
return todos
case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(t => t.completed)
case VisibilityFilters.SHOW_ACTIVE:
return todos.filter(t => !t.completed)
default:
throw new Error('Unknown filter: ' + filter)
}
}
const mapStateToProps = state => ({
// *** state.todosをstate.visibilityFilter でフィルタし、TodoList に渡す
todos: getVisibleTodos(state.todos, state.visibilityFilter)
})
const mapDispatchToProps = dispatch => ({
toggleTodo: id => dispatch(toggleTodo(id)) // *** (2) TOGGLE_TODO action
})
// *** TodoList には store の情報が props として渡される
export default connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)
Redux Hooks
import { useDispatch, useSelector } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { VisibilityFilters } from '../actions'
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case VisibilityFilters.SHOW_ALL:
return todos
case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(t => t.completed)
case VisibilityFilters.SHOW_ACTIVE:
return todos.filter(t => !t.completed)
default:
throw new Error('Unknown filter: ' + filter)
}
}
const todosSelector = state => getVisibleTodos(state.todos, state.visibilityFilter);
export default props => {
const dispatch = useDispatch();
const todos = useSelector(todosSelector);
return ( <TodoList children={props.children} todos={todos} toggleTodo = {id => dispatch(toggleTodo(id))} /> )
}
TodoList は Presentational で、上から渡された props を使って render しているだけです。Redux については何も知りません。
import React from 'react'
import PropTypes from 'prop-types'
import Todo from './Todo'
const TodoList = ({ todos, toggleTodo }) => (
<ul>
{todos.map(todo =>
<Todo
key={todo.id}
{...todo} // *** (1)
onClick={() => toggleTodo(todo.id)}
/>
)}
</ul>
)
TodoList.propTypes = {
todos: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number.isRequired,
completed: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired
}).isRequired).isRequired,
toggleTodo: PropTypes.func.isRequired
}
export default TodoList
(1) JSXでは、スプレッド構文を使って props オブジェクトそのものを渡すことができます。
import React from 'react'
import PropTypes from 'prop-types'
const Todo = ({ onClick, completed, text }) => (
<li
onClick={onClick}
style={{
textDecoration: completed ? 'line-through' : 'none'
}}
>
{text}
</li>
)
Todo.propTypes = {
onClick: PropTypes.func.isRequired,
completed: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired
}
export default Todo
(3)Footer
Footer (Presentational) はフッターの FilterLink を render している Presentational component です。
import React from 'react'
import FilterLink from '../containers/FilterLink'
import { VisibilityFilters } from '../actions'
const Footer = () => (
<div>
<span>Show: </span>
<FilterLink filter={VisibilityFilters.SHOW_ALL}>
All
</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>
Active
</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>
Completed
</FilterLink>
</div>
)
export default Footer
FilterLink component は Container であり、子供の Link component (Presentational) が表示に使う Redux store のデータの準備をし、受け渡す役割を果たします。
つまり FilterLink (Container) は現在の state.visibilityFilter をみて判断したボタンの disabled 情報と、 SET_VISIBILITY_FILTER action に紐づけた onClick ハンドラ を、下位の Link (Presentational) に渡します。
import { connect } from 'react-redux'
import { setVisibilityFilter } from '../actions'
import Link from '../components/Link'
// *** 表示するボタンが現在表示中のものならば、active=true にする。
// *** 現在の state.visibilityFilter と、親において指定された ownProps.filter を比較する
const mapStateToProps = (state, ownProps) => ({
active: ownProps.filter === state.visibilityFilter
})
const mapDispatchToProps = (dispatch, ownProps) => ({
onClick: () => dispatch(setVisibilityFilter(ownProps.filter)) // *** (3) SET_VISIBILITY_FILTER action
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(Link)
Redux Hooks
import { useDispatch, useSelector } from 'react-redux'
import { setVisibilityFilter } from '../actions'
import Link from '../components/Link'
const visibilityFilterSelecter = state => state.visibilityFilter
export default props => {
const dispatch = useDispatch();
const onClick = () => dispatch(setVisibilityFilter(props.filter));
const active = props.filter === useSelector(visibilityFilterSelecter);
return (<Link children={props.children} active={active} onClick={onClick} />)
}
ownProps はconnect() 関数 を利用する Container component が親から渡された props です。この場合、ownProps は Footer component から渡された props です。「2-2-3.React Reduxの概略」を参照。
Link は Presentational で、上から渡された props を使って render しているだけです。Redux については何も知りません。
また React のコンポーネントに渡される props.children とは、コンポーネントの開始タグと終了タグの間に書かれた要素やテキストのことです。children は、コンポーネントに任意の数や種類の子要素を渡すことができる便利な機能です。childrenは特別なpropsなので、明示的に渡す必要はありません
import React from 'react'
import PropTypes from 'prop-types'
const Link = ({ active, children, onClick }) => (
<button
onClick={onClick}
disabled={active}
style={{
marginLeft: '4px',
}}
>
{children}
</button>
)
Link.propTypes = {
active: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onClick: PropTypes.func.isRequired
}
export default Link
今回は以上です。