これはなに
新しめのReact (※) にはContextとHooksという2つのAPIが追加されており、これらを用いるとReduxで実装するようなFluxパターンを実現することが可能です。いつかくる最終戦争によって世界中のReduxが滅んでも、Reactさえ使えればクリーンな状態管理を続けることができるという訳ですね。
Reduxのサンプルでよくある絞り込み機能付きのToDoリストを用いて、storeを作ってblobal stateを共有しcomponentから利用するところまでを解説します。コードはGitHubにあります。
また、以下の本文中に添付するコードは可読性の都合で一部改変しているところがあります。ご了承ください。
※ - Context APIは v16.3.0、Hooks APIはv16.8.0 からです。
構成
src
ディレクトリの中身はこんな感じです。
.
├── App.css
├── App.tsx
├── components
│ ├── TodoAdd
│ │ └── index.tsx
│ ├── TodoList
│ │ └── index.tsx
│ └── VisibilityFilterSelect
│ └── index.tsx
├── index.scss
├── index.tsx
├── react-app-env.d.ts
├── store
│ ├── index.tsx
│ ├── todos
│ │ ├── actions.ts
│ │ ├── index.tsx
│ │ ├── reducer.ts
│ │ └── types.ts
│ ├── visibilityFilter
│ │ ├── actions.ts
│ │ ├── index.tsx
│ │ ├── reducer.ts
│ │ └── types.ts
│ └── visibleTodos
│ └── index.tsx
└── types
└── index.ts
ぱっと見た感じはReact-Reduxプロジェクトみのある構成ですね。
storeを作る
型を定義する
TypeScriptを使う場合のみですが、storeの型を定義しておきます。
todos
の型はこんな感じです。
export type TodosState = {
id: number;
text: string;
completed: boolean;
}[];
export type TodosAction = {
type: "ADD_TODO" | "TOGGLE_TODO";
id?: number;
text?: string;
};
export type TodosContextType = React.Context<{
state: TodosState;
dispatch: React.Dispatch<TodosAction>;
}>;
TodosState
と TodosAction
はReduxでも使うので「あーアレね」という感じだと思います。 TodosContextType
はReduxでいう provider
の中身に相当するもので、一旦 state
をそれを変更するための dispatch
が入る…ということだけ定義しておきます。
reducerを定義する
stateとactionの型が決まったので、Reducerを作っていきます。
export default (
state: TodosState,
action: TodosAction
): TodosState => {
switch (action.type) {
case "ADD_TODO":
return (action.text)
? [...state, {
id: state.length,
text: action.text,
completed: false,
}]
: state;
case "TOGGLE_TODO":
return state.map(
(item) => (item.id === action.id)
? { ...item, completed: !item.completed }
: item
);
default:
return state;
}
};
まあ特に変なところはないですね。Reduxで使うReducerとなんら変わらないと思います。
actionsを定義する
Reducerに変更を通達するactionsを実装します。
export const addTodo = (text: string): TodosAction => ({
type: "ADD_TODO",
text,
})
export const toggleTodo = (id: number): TodosAction => ({
type: "TOGGLE_TODO",
id,
});
普通ですね。
ContextProviderを定義する
やってきました。本丸です。
Storeを作成し、その状態と変更関数を子componentに配布していきます。
const initialState: TodosState = [];
export const TodosContext: TodosContextType = React.createContext<{
state: TodosState;
dispatch: React.Dispatch<TodosAction> | Noop;
}>({
state: initialState,
dispatch: () => {},
});
const TodosContextProvider: React.FC<React.Props<{}>> = ({
children,
}): JSX.Element => {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<TodosContext.Provider value={{ state, dispatch }}>
{children}
</TodosContext.Provider>
);
};
export default TodosContextProvider;
なげえ。一気にややこしくなりました。
順番に解説します。
まずは Context API
を使って子componentに渡すための Context
を実装しています。
ちなみに引数に渡す値は初期値ではなく 子componentが読み込みを試みたとき該当するProviderが無かった際に使われる値 です。実質ほぼ使われないのですが、適当にnullとか入れると型解析の都合が悪くなることなどもあり、いちおう初期値いれとくか…というぐらいの気持ちに落ち着きました。
子componentから呼び出す際にも使用するので export
を忘れないように気をつけます。
const initialState: TodosState = [];
export const TodosContext: TodosContextType = React.createContext<{
state: TodosState;
dispatch: React.Dispatch<TodosAction> | Noop;
}>({
state: initialState,
dispatch: () => {},
});
で、いま作った Context
に渡すReducerを作ります。
useReducer
はreducerと初期値を受け取って state
と dispatch
を返します。Hooksを利用したことでこれらはこのcomponentのstateとして管理されるため、Context APIをよく考えずに使うと起きる「状態更新の度に再描画が走る」リスクは心配無用だと思います。
また、Context Providerは1つのstoreにつき1つ定義することになるのでどうやってもネスト地獄が発生します。変に結合させるとややこしくなるので children
を受け取る形で実装しておき、後でまとめるとよいかと思います。
const TodosContextProvider: React.FC<React.Props<{}>> = ({
children,
}): JSX.Element => {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<TodosContext.Provider value={{ state, dispatch }}>
{children}
</TodosContext.Provider>
);
};
export default TodosContextProvider;
storeを組み込む
いよいよアプリケーションにstoreを組み込んでゆきます。
const ContextProvider: React.FC<React.Props<{}>> = ({
children,
}): JSX.Element => (
<VisibilityFilterContextProvider>
<TodosContextProvider>
<VisibleTodosContextProvider>
{children}
</VisibleTodosContextProvider>
</TodosContextProvider>
</VisibilityFilterContextProvider>
);
export default ContextProvider;
ネスト地獄です。こればかりは避けられませんが、逆にこういうAPI都合の謎構造は一箇所に固めて (ある程度) ブラックボックス化させておけるので、割り切ることもできるような気がしています。
特に言うことはないですが、後で解説するように「複数storeのstateを集約する」みたいなことをやろうとすると、ネストの順番が割と大事になってくるので注意が必要です。
最後にこれらをアプリケーションに接続します。 App.tsx
的な、ルート階層に近い部分で行います。このサンプルだと src
直下ですね。
const App: React.FC = (): JSX.Element => (
<ContextProvider>
<div className="container">
<TodoAdd />
<VisibilityFilterSelect />
<TodoList />
</div>
</ContextProvider>
);
ReactDOM.render(<App />, document.getElementById('root'));
これでアプリケーションとstoreがつながりました。
いよいよ状態の読み出しと更新を行なっていきます。
storeを使う
ToDoリストへの項目追加を行う TodoAdd
コンポーネントを例に解説します。
const TodoAdd: React.FC = (): JSX.Element => {
const { state, dispatch } = React.useContext(TodosContext);
const [ text, setText ] = React.useState("");
const handleClick = () => {
if (!dispatch) return;
dispatch(addTodo(text));
setText("");
}
return (
<form className="TodoAdd">
<input
type="text"
value={text}
onChange={e => setText(e.currentTarget.value)}
className="TodoAdd__input"
/>
<button onClick={handleClick} disabled={text === ""} className="TodoAdd__button">追加</button>
</form>
)
}
export default TodoAdd;
大事なのは useContext
を利用してContextから値を取り出し、そのまま利用できるという点ですね。
// ↓ここでContextを読み込んで
import { TodosContext } from "../../store/todos";
const TodoAdd: React.FC = (): JSX.Element => {
// ↓ ここで値を取り出している!
const { state, dispatch } = React.useContext(TodosContext);
const [ text, setText ] = React.useState("");
const handleClick = () => {
if (!dispatch) return;
// ↓ アクションをdispatchして状態を更新できる!
dispatch(addTodo(text));
setText("");
}
// ...
いいですね〜Reduxの connect
に比べてシンプルな印象がないでしょうか。そんなことないですか。
おまけ
combinated storeを作る
2つ以上のstoreを結合したいときには単純にcontextをたくさん読み込んで計算した結果を新たなcontextとして配布すればよいのではないかと思います。
以下は visibilityFilter
(表示するToDo項目の絞り込み) と todos
(ToDo全項目) を使って「表示するToDo項目」を作っているものです。
export const visibleTodosContext = React.createContext<{
state: TodosState;
}>({
state: initialState,
});
const VisibleTodosContextProvider: React.FC<React.Props<{}>> = ({
children,
}): JSX.Element => {
const { state: todosState } = React.useContext(TodosContext);
const { state: visibilityFilterState } = React.useContext(VisibilityFilterContext);
const stateFactory = (todos: TodosState): TodosState => {
switch(visibilityFilterState) {
case "SHOW_ALL":
return todos;
case "SHOW_ACTIVE":
return todos.filter(todo => !todo.completed)
case "SHOW_COMPLETED":
return todos.filter(todo => todo.completed)
default:
return todos;
}
}
return (
<visibleTodosContext.Provider value={{ state: stateFactory(todosState) }}>
{children}
</visibleTodosContext.Provider>
)
}
export default VisibleTodosContextProvider;
前述しましたが、 Context APIのProviderは子componentにしか状態を伝播させない ので、これを ContextProvider
のルート階層に持っていくとエラーになります。
ちょっと気持ち悪さがありますが、やむを得ない感じもあります。
課題など
今回は使いませんでしたが、ほとんどのアプリケーションではAPIへのアクセスなど非同期処理が発生すると思います。今の所Reducer Hookにはmiddlewareの利用にデファクト的なものがないっぽいので、どうハンドリングするかは少し考える必要があります。同期的に処理の開始と完了で2つactionを発行するという手もあるし、自前でmiddleware的なものを実装する という手もあるでしょう。
まとめ
これでRedux封じに遭っても安心ですね。
今はまだ「使えんこともない」という感じですが、こうしたReact本来のAPIを活かしつつ拡張していくようなエコシステムが成熟してくると大分可能性はあるな…と感じます。以上です。