※ ReducerとVIEWコンポーネントの接続方法に関する記事を追加しました。
ReactHooks学習用にTodoリストを作成した際のまとめです。
Reactの基本事項は既に習得されている方向けです。
ReactのJSX記法を利用する為、Babelなどのトランスパイラを利用します。
webpackなどのモジュールバンドラーの利用も前提としています。
今回は、Parcelを利用しています。
Fluxとは
Reactによるアプリケーション構築を保管する為に考案された、設計パターンです。
データの流れを一方通行にすること、アプリケーションの状態「State」を一元的に管理することで、アプリケーションの管理が煩雑化することを防ぐ試みです。
Fluxによるアプリケーションは、下記のパートで構成されます。
- Dispatcher: アプリケーションへの更新情報を集約・通知
- Store: アプリケーションの状態を保持・管理
- Action: アプリケーションの更新情報を得る為の内部API
- View: Reactコンポーネントによるインターフェース
Dispatcherは、アプリケーションに1つだけ存在します。
Storeは、アプリケーションの規模などに応じて1つ以上存在します。
Actionは、アプリケーションに発生しうる変更の数だけ存在します。
Vewは、Storeと接続したコンポーネントを頂点としてツリー構造を形成します。
詳細はこちらの記事で扱っております。
FluxによるReactアプリの状態管理 Flux・FluxUtils編
本家サイトはこちら。
Flux
このFluxを実装する為のフレームワークとして、公式よりFlux Utilsが提供されています。
しかし、React本体に導入されたHooksだけで、Fluxの実装が可能になりました。
Hooksとは
ReactのVer.16.8で新たに導入された機能群です。
Class構文を利用せずに、シンプルな関数の組み合わせでアプリケーションを構築することを可能としてくれます。
2019年9月時点で、10個の組み込みHookが提供されています。
Reactのパッケージに含まれており、共通して名前がuse-
で始まる関数です。
その他、自分でカスタムHookを作成することも可能です。
import React { useState, useEffect } from 'react';
Todoアプリ
ReduxのベーシックチュートリアルにあるTodoサンプルをベースにしてみました。
Hooksを使った構築は、Flux思想を忠実に再現したFluxUtilsを使用して構築した場合と比較すると、Reduxに幾分か寄っている印象です。
その為、Redux・React-Reduxで構築した場合の構造をだいたい維持したまま、Hooksに置き換えてみました。
このRedux・React-ReduxのTodoアプリについてはこちらで扱っております。
FluxによるReactアプリの状態管理 Redux・React-Redux編
この記事で作成するTodoアプリの完全なソースコードはこちらです。
todoapp-flux-hooks
動作のサンプル
Actions
Actionは、Reducerに対して送信するデータです。
実体はJavaScriptのオブジェクトです。
Actionの種類を示す、type
プロパティを必ず持っています。type
プロパティの値は、一般的に文字列が用いられます。
アプリケーションの規模が大きい場合は、予め定数として定義しておくことが望ましいでしょう。
その他Reducerに送信したいデータが含まれます。
// ActionType
const ADD_TODO = 'ADD_TODO'
// Action
{
type: ADD_TODO,
text: 'Build my first Redux app'
}
このActionを生成するのがActionCreatorです。
Fluxの基本では、Actionの生成から送信までをActionCreator内で実行することになっています。
今回はReduxと同じく、生成して返すのみです。
// Actionの識別子
export const TodoActionTypes = {
ADD_TODO: 'ADD_TODO',
/* 略 */
};
// TodoID
let nextTodoId = 0;
/**
* Todo項目の追加
* @param {String} text Todoテキスト
* @returns {Object} Todo追加用action
*/
export const addTodo = (text) => ({
type: TodoActionTypes.ADD_TODO,
id: nextTodoId++,
text
});
アプリケーションのStateの設計
アプリケーションの状態を一つのオブジェクトで表す「State」を設計します。
今回のTodoアプリのStateは、以下のようになります。
{
visibilityFilter: 'SHOW_ALL',
todos: [
{
id: 1,
text: 'Write a article about Redux',
completed: true
},
{
id: 2,
text: 'Go to the Cats cafee',
completed: false
}
]
}
visibilityFilterの指定には、予め用意した定数を用います。
export const VisibilityFilters = {
SHOW_ALL: 'SHOW_ALL',
SHOW_COMPLETED: 'SHOW_COMPLETED',
SHOW_ACTIVE: 'SHOW_ACTIVE'
};
Reducers
Reducerとは
Reducerは、本家FluxではなくReduxで登場するモジュールです。
FluxのStoreの、Stateの変更をする責務を切り出したものです。
これは予めツールなどが用意されているわけではなく、一定のルールに基づいて自分で構築する関数です。
React側では特に指示がないので、Redux側のルールに基づいて作成することとします。
Reducerは、副作用のない純粋な関数です。
以前のStateと新たに生成されたActionを受け取り、新しいStateを返します。
(previousState, action) => newState
previousState
とaction
の内容が同一であれば、何度呼び出しても同じnewState
を返します。
その為、Reducerには下記のルールがあります。
- 引数を変更してはいけません。
- APIの呼び出しやルーティングなどの、副作用が発生する処理を記述してはいけません。
- 純粋でない関数を呼び出してはいけません。(例:Date.now(), Math.random())
function todoApp(previousState, action){
switch(action.type){
case 'TYPE_A':
return newState;
case 'TYPE_B':
return newState;
default:
return previousState;
}
}
ActionTypeでActionの種類を識別し、previousState
とaction
の情報を基に新しいStateを生成します。
初期呼び出し時は、previousState
はundefined
が渡される為、デフォルト引数には、Stateの初期値を設定しています。
Reduxでは、state
の初期値をReducerのデフォルト引数として用意します。
しかし、Hooksを利用する場合は不要になります。
Reducerの分割
アプリケーションの規模が大きい場合、StateおよびReducerのサイズも大きくなってきます。
その場合、一つのReducerで管理することが難しくなる為、適宜分割することが望ましいでしょう。
todos
とvisibilityFilter
、それぞれ管理するReducerを作成します。
const todos = (state, action) => {
switch (action.type) {
case 'ADD_TODO':
return // Todo項目追加後newState
case 'TOGGLE_TODO':
return // Todo項目切替後newState
default:
return state;
}
};
export default todos;
const visibilityFilter = (state, action) => {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return action.filter;
default:
return state;
}
};
export default visibilityFilter;
Reducerの結合
関数を分割したものの、最終的に必要なStateは一つのオブジェクトです。
その為、複数のReducerの結果をまとめるReducerが必要です。
import todos from './todos';
import visibilityFilter from './visibilityFilter';
const rootReducers = (state = {}, action = {}) => {
return {
todos: todos(state.todos, action),
visibilityFilter: visibilityFilter(state.visibilityFilter, action)
};
};
export default rootReducers;
Reduxでは複数のReducerを束ねる為のcombineReducers()関数が提供されています。
おおよそ下記のようなコードです。
/**
* Reducerをまとめるヘルパー関数
* @param {Object} reducers reducerの連想配列
* @returns {Function} 結合後のReducer関数
*
* 参考: https://github.com/reduxjs/redux/blob/master/src/combineReducers.js
*/
const combineReducer = (reducers) => {
const reducerKeys = Object.keys(reducers);
const finalReducers = {};
for (let key of reducerKeys) {
const reducer = reducers[key];
if (typeof reducer === 'function') {
finalReducers[key] = reducer;
}
}
const finalReducerKeys = Object.keys(finalReducers);
return (state, action) => {
let newState = {};
let hasChanged = false;
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i];
const reducer = finalReducers[key];
const prevState = state[key];
const nextState = reducer(prevState, action);
if (typeof nextState === 'undefined') {
throw new Error(`「 ${key} 」は存在しないStateです。`)
}
newState[key] = nextState;
hasChanged = hasChanged || (nextState !== prevState);
}
return hasChanged ? newState : state;
};
};
// 利用
export default combineReducer({
todos,
visibilityFilter
});
useReducerを利用したFluxの構築
Fluxは基本的に、Viewのコンポーネントの外で一元的にアプリケーションの状態を管理する仕組みです。
FluxUtilsではDispatcherとStoreが、ReduxではStoreとReducerというViewから独立したモジュールがこの責務を負っていました。
責務の微妙な違いはあれどStateを保持する「Store」は両方にあります。
Hooksを利用した場合、Stateの保持はView側のルートコンポーネントが担当することになるようです。
実装にはHooksの組み込み関数「useReducer」を利用します。
import React, { useReducer } from 'react';
import Reducer from '../reducers';
import App from '../components/';
export default function(){
const [state, dispatch] = useReducer(Reducer, initialState);
return (
<App state={state} dispatch={dispatch} />
);
}
useReducer()
の第一引数にReducerを渡します。第二引数はStateの初期値です。
戻り値として、Viewで保持するStateと、State更新の為のdispatch関数を受け取ります。
以降、このdispatch関数の引数にActionを渡すと、Reducerが実行され、新たなstateがViewに渡されるようになります。
FluxUtilsやReact-Reduxでは、ViewにStateを引き渡す前に以前のStateと新しいStateの比較を行っていました。
useReducerを利用した場合も同様の比較確認が行われ、変更を検知できた場合に再レンダリングが発生します。
ContextとuseContext
ルートコンポーネントで保持されているStateは、基本気にpropsを通じて下位コンポーネントへと共有されます。
その階層がふかくなると、長いバケツリレーが発生してしまいます。
Ver.16.3で導入された「Context」を利用すると、下位コンポーネントからも等しくStateにアクセスできるようになります。
利用準備
Contextオブジェクトを生成して公開します。
アプリケーション内で複数作成することが可能です。
import React from 'react';
const MyContext = React.createContext(defaultValue);
export default MyContext;
// 初期値defaultValueは省略可
データの提供
Contextオブジェクトに付属するProviderコンポーネントを利用して、提供したいデータを登録します。
import MyContext from './myContext';
import RootApp from './rootApp';
function App(){
// valueプロパティにに渡す
return (
<MyContext.Provider value={AppStateData}>
<RootApp />
</MyContext.Provider>
);
}
データの読み取り
Providerを通して提供されたデータを、下位のコンポーネントが直接受け取るには、2種類の方法があります。
Consumerコンポーネント
Contextオブジェクトに付属するConsumerコンポーネントを利用して、Providerで提供されたデータにアクセスします。
Consumerコンポーネントにコールバック関数を渡すと、引数でProviderのvalueプロパティに渡されたデータを受け取れます。
import MyContext from './myContext';
function SomeContainer(){
return (
<MyContext.Consumer>
{({ param, callBack }) => {
<button onClick={callBack}>
{param}
</button>
}}
</MyContext.Consumer>
);
}
useContext
useContextを利用すると、Consumerコンポーネントよりもすっきりと記述できます。
useContextの引数にContextオブジェクトを渡すと、戻り値としてStateを受け取れます。
import React, { useContext } from 'react';
import MyContext from './myContext';
function SomeContainer(){
const { param, callBack } = useContext(MyContext);
return (
<button onClick={callBack}>
{param}
</button>
);
}
PresentationalComponents と ContainerComponents
ReactとReduxでアプリケーションを構築する際は、基本的にコンポーネントをPresentationalComponentとContainerComponentの2種に分けます。
PresentationalComponentはビジネスロジックを持たず、描画に徹します。
ContainerComponentはReduxと接点を持ち、PresentationalComponentに必要なpropsを受け渡します。
こうすることで、より見通しの良いアプリケーションの構築を可能とし、コンポーネントの再利用性を高めます。
Reducerの時と同様、View側もReduxのルールを取り入れて構築することとします。
Presentational and Container Components | Redux
ルートコンポーネントの場合
ルートコンポーネントでは、useReducerを用いてReducerと接続しつつ、Contextで下位コンポーネントへデータを提供する仕組みを準備します。
import React, { useReducer } from 'react';
import TodoContext from '../context';
import TodoReducer from '../reducers';
import TodoApp from '../components/';
import { VisibilityFilters } from '../actions';
const initialState = {
visibilityFilter: VisibilityFilters.SHOW_ALL,
todos: []
};
const App = () => {
const [state, dispatch] = useReducer(TodoReducer, initialState);
const value = { ...state, dispatch };
return (
<TodoContext.Provider value={value}>
<TodoApp />
</TodoContext.Provider>
);
};
export default App;
useReducerを実行して得られたstate
とdispatch
を、Providerコンポーネントへ渡します。
その際、stateとdispatchをまとめたオブジェクトを作成してvalueプロパティに渡しています。
Contextを利用するに当たり、この毎回オブジェクトを作成して渡すというのはパフォーマンス上、あまりよろしくないそうです。
Providerを含むコンポーネントが再レンダリングされる度に、Providerに渡しているオブジェクトが新しくなってしまうため、value
に渡しているStateに変更が無くとも関連するConsumerを含むコンポーネントの再レンダリングが発生してしまいます。
上記の例では、Stateの更新以外に再レンダリングが発生する要因が無いので、問題ないかなと思っています。
TodoListの場合
Todoリストを表示するコンポーネントのツリーは下記のとおりです。
VisibleTodoListがContainerComponentsです。
Provider
└ VisibleTodoList ☆
└ TodoList
└ Todo
useContextを使ってStateから必要な項目を取得します。
todoリストの配列は現在のフィルターにマッチするものを抽出して、下位コンポーネントに渡します。
また、項目をクリック時に完了状況を切り替えるためのコールバック関数も定義しています。
import React, { useContext } from 'react';
import TodoContext from '../context';
import TodoList from '../components/todoList';
import {
toggleTodo,
VisibilityFilters
} from '../actions';
const VisibleTodoList = () => {
const { todos, visibilityFilter, dispatch } = useContext(TodoContext);
let visibleTodos;
switch (visibilityFilter) {
case VisibilityFilters.SHOW_ALL:
visibleTodos = todos;
break;
case VisibilityFilters.SHOW_COMPLETED:
visibleTodos = todos.filter(t => t.completed);
break;
case VisibilityFilters.SHOW_ACTIVE:
visibleTodos = todos.filter(t => !t.completed);
break;
default:
throw new Error('Unknown filter ' + visibilityFilter);
}
return (
<TodoList
todos={visibleTodos}
toggleTodo={id => { dispatch(toggleTodo(id)) }}
/>
);
};
export default VisibleTodoList;
AddTodoの場合
Todoに項目を追加するコンポーネントを構築します。
Form要素を含むコンポーネントなど、責務の分離が難しい場合は、分離しないという選択肢もあります。
import React, { useState, useContext } from 'react';
import TodoContext from '../context';
import { addTodo } from '../actions';
const AddTodo = () => {
const [inputText, setInputText] = useState('');
const { dispatch } = useContext(TodoContext);
return (
<div>
<form
onSubmit={e => {
e.preventDefault();
if (!inputText.trim()) {
return;
}
dispatch(addTodo(inputText));
setInputText('');
}}
>
<input
value={inputText}
onChange={e => setInputText(e.target.value)}
/>
<button type="submit">Add Todo</button>
</form>
</div>
);
};
export default AddTodo;
useContext
関数コンポーネント内で、ローカルなStateを持つ為のHookです。
inputフォームの値を保持する為に利用しています。
const [state, setState] = useState(initialState);
イメージ図
2019年6月30日追記
Reduxのからの流れで当たり前の様にReducerを一つにまとめていたのですが、ReactのReducerは必ずしも一つにまとめる必要はなさそうです。
下記の記事を参考にいたしました。
How to use useReducer in React Hooks for performance optimization
useReducerを複数利用する
わざわざcombineReducer
でまとめなくても、FluxのStoreがそうであるように、ReactのReducerも管理単位ごとに複数存在させます。
それに応じて接点を提供するuseReducerも複数利用します。
import React, { useReducer } from 'react';
import TodoContext from '../context';
import {
TodosReducer,
FilterReducer
} from '../reducers';
import TodoApp from '../components/';
import { VisibilityFilters } from '../actions';
const App = () => {
const [todos, dispatchTodo] = useReducer(TodosReducer, []);
const [visibilityFilter, dispatchFilter] = useReducer(FilterReducer, VisibilityFilters.SHOW_ALL);
const value = {
todos,
visibilityFilter,
dispatchTodo,
dispatchFilter
};
return (
<TodoContext.Provider value={value}>
<TodoApp />
</TodoContext.Provider>
);
};
export default App;
本記事はあくまでFluxの記事なのでuseReducer
によるReducerとの接点をVIEWのルートコンポーネントにしか置いていませんが、useReducer
およびReducerは、個々のコンポーネントのローカルStateの管理に用いることも、もちろん可能です。
// 一つの関数コンポーネント内でstateの管理が煩雑化した場合に、外部化させる
const localReducer = (state, action) => {
switch (action.type) {
case 'hoge':
return {
...state,
hoge: action.value
};
// ...
}
}
const SomeComponent = (props) => {
const [state, dispatch] = useReducer(localReducer, initialState);
const onClick = (value) => {
dispatch({
type: 'hoge',
value
});
};
// ...
}
Reducer毎にContextを用意する
ContextもReducer毎に用意して、もうちょっとルートコンポーネントにおける接続部分をすっきりさせてみます。
Hooksを利用した新Store
TodosのReducerとContextを、useReducerを使ってひとまとめにします。
import React, { useReducer, useContext } from 'react';
import { TodoActionTypes } from '../actions';
const Context = React.createContext();
// Reducer
const reducer = (state, action) => {
switch (action.type) {
case TodoActionTypes.ADD_TODO:
return [
...state,
{
id: action.id,
text: action.text,
completed: false
}
];
case TodoActionTypes.TOGGLE_TODO:
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
default:
return state;
}
};
// Todos専用Provider
const TodosProvider = ({ children }) => {
const contextValue = useReducer(reducer, []);
return (
<Context.Provider value={contextValue}>
{children}
</Context.Provider>
);
};
// TodosのstateとDispatcherを提供するカスタムHook
const useTodos = () => {
const contextValue = useContext(Context);
return contextValue;
}
export { TodosProvider, useTodos };
useTodos
はカスタムHookと呼ばれる、ユーザが独自に定義できるHookです。
内部でビルトインHooksを利用しつつも、Reactコンポーネントは返しません。
VisibilityFilterも同様にひとまとめにします。
ルートコンポーネントでは、各Storeから提供されているProvider
を設置するだけです。
ロジックが無くなってすっきりしたので、VIEWをまとめる為だけの中間コンポーネントは廃止しました。
import React from 'react';
import {
TodosProvider,
FilterProvider
} from '../stores';
import AddTodo from './addTodo';
import VisibleTodoList from './visibleTodoList';
import Footer from '../components/footer';
const App = () => {
return (
<FilterProvider>
<TodosProvider>
<AddTodo />
<VisibleTodoList />
<Footer />
</TodosProvider>
</FilterProvider>
);
};
export default App;
下位のContainerコンポーネントでは、各Storeから提供されているカスタムHookを利用します。
カスタムHookもビルトインHookと同様、コンポーネントのトップレベルのスコープで実行する必要があります。
import React from 'react';
import {
useTodos,
useFilter
} from '../stores';
import {
VisibilityFilters,
toggleTodo
} from '../actions';
import TodoList from '../components/todoList';
const VisibleTodoList = () => {
// useReducer利用時と同じ要領で分割代入
const [todos, dispatch] = useTodos();
const [visibilityFilter] = useFilter();
let visibleTodos;
switch (visibilityFilter) {
case VisibilityFilters.SHOW_ALL:
visibleTodos = todos;
break;
case VisibilityFilters.SHOW_COMPLETED:
visibleTodos = todos.filter(t => t.completed);
break;
case VisibilityFilters.SHOW_ACTIVE:
visibleTodos = todos.filter(t => !t.completed);
break;
default:
throw new Error('Unknown filter ' + visibilityFilter);
}
return (
<TodoList
todos={visibleTodos}
toggleTodo={id => { dispatch(toggleTodo(id)) }}
/>
);
};
export default VisibleTodoList;
Providerコンポーネントは、必ずしもルートに全部まとめる必要は無いかと思います。
ここまでのコードはこちらにあります。
multiple_store | github
ReduxとContextの接続ツール
todosStore
とvisibilityFilterStore
で同じ事をやっているので、まとめられるのでは?と思ってやってみました。
import React, { useReducer, useContext } from 'react';
/**
* ReducerとContextを接続する
* @param {Function} reducer 対象Reducer
* @param {*} initialState state初期値
*/
const connect = (reducer, initialState) => {
const Context = React.createContext();
const Provider = ({ children }) => {
const contextValue = useReducer(reducer, initialState);
return (
<Context.Provider value={contextValue}>
{children}
</Context.Provider>
);
};
Provider.displayName = reducer.name && (reducer.name !== 'anonymous') ? `Provider(${reducer.name})` : 'Provider';
const useConsumer = () => {
const contextValue = useContext(Context);
return contextValue;
};
return { Provider, useConsumer };
};
export default connect;
ここで登場するdisplayName
は、JavaScriptの非推奨プロパティのdisplayNameではなく、Reactコンポーネントのプロパティです。
デバッグツール利用時に表示される名前を指定するのに利用します。
Reducerとの接続
import connect from './connect';
// Reducer
const todos = (state, action) => {
switch (action.type) {
case TodoActionTypes.ADD_TODO:
return [
...state,
{
id: action.id,
text: action.text,
completed: false
}
];
case TodoActionTypes.TOGGLE_TODO:
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
default:
return state;
}
};
// Reducerと初期値を渡すのみ
export default connect(todos, []);
Providerの利用
import TodosStore from '../stores/todoStore';
import FilterStore from '../stores/visibilityFilterStore';
const App = () => {
return (
<FilterStore.Provider>
<TodosStore.Provider>
<AddTodo />
<VisibleTodoList />
<Footer />
</TodosStore.Provider>
</FilterStore.Provider>
);
};
Consumerの利用
React.useState
という記述方法に倣ってみました。
ただ、eslint-plugin-react-hooksではHooksと認識されませんでした。
一応これで無事動いてはいるようです。ご参考までに……。
import TodosStore from '../stores/todoStore';
import FilterStore from '../stores/visibilityFilterStore';
const VisibleTodoList = () => {
const [todos, dispatch] = TodosStore.useConsumer();
const [visibilityFilter] = FilterStore.useConsumer();
let visibleTodos;
switch (visibilityFilter) {
case VisibilityFilters.SHOW_ALL:
visibleTodos = todos;
break;
case VisibilityFilters.SHOW_COMPLETED:
visibleTodos = todos.filter(t => t.completed);
break;
case VisibilityFilters.SHOW_ACTIVE:
visibleTodos = todos.filter(t => !t.completed);
break;
default:
throw new Error('Unknown filter ' + visibilityFilter);
}
return (
<TodoList
todos={visibleTodos}
toggleTodo={id => { dispatch(toggleTodo(id)) }}
/>
);
};
参考情報
React HooksとContextAPIでFluxをやってみる
Redux, Flux, and the React Hooks API, Which should I use?