ReactHooks + Context API によるReduxの実装

ContextAPIとReactHooksを使用することによってRedux同様にstateを一限管理することが可能です。
しかしながら、あくまでReduxに似せているだけであり、まったくの別物です。また、Reduxとのそのもそものコンセプトが違っています。Reduxを採用するのかReactHooks + Context APIを使用するのかはプロジェクトで適宜考えてください。

自身が使った所感としてはReactHooks + Context APIで実装するとコンポーネントが独立し、よりReactらしいコードを書くことができる、Reduxのように複数のファイルを管理する必要がないため少ないファイル数とコードで済ませたい場合などに向いているように思います。

実装

hooks.tsx

import React, { useReducer, useContext, createContext, Dispatch } from "react";

type StateType = {
    count: number;
}

const initialState: StateType = {
    count: 0
}

type ActionType = {
    type : "INCREMENT"|"DECREMENT"|"RESET"
}

const reducer = (state: StateType, action: ActionType) => {
    switch (action.type) {
        case "INCREMENT":
            return { ...state, count: state.count + 1 };
        case "DECREMENT":
            return { ...state, count: state.count - 1 };
        case "RESET":
            return initialState;
        default:
            return state;
    }
}

interface StoreContextProps {
    state: StateType,
    dispatch: Dispatch<ActionType>;
}

export const StoreContext = createContext({} as StoreContextProps);

interface StoreProviderProps {
    children: JSX.Element | JSX.Element[];
}

export const StoreProvider: React.FC<StoreProviderProps> = (props: StoreProviderProps): JSX.Element => {
    const {children} = props;
    const [state, dispatch] = useReducer(reducer, initialState);
    return <StoreContext.Provider value={{ state, dispatch }}>{children}</StoreContext.Provider>
}

export const useCountContext = (): React.ContextType<typeof StoreContext> => useContext(StoreContext);

一つのファイル内でreducerとcontext, Providerを定義しておきます。
reducerはRedux同様にstateの状態を管理する場所になります。

Provider内に存在するコンポーネントはどの場所からでもuseCountContextを使用することでstateとdispatchが呼び出せるようになります

App.tsx

import React from "react";
import { useCountContext, StoreProvider } from "./hooks";

const ChildComponent: React.FC = (): JSX.Element => {
    const { state, dispatch } = useCountContext();
    return (
      <>
          <p>{state.count}</p>
          <button onClick={e => {
                  e.preventDefault();
                  dispatch({ type: "RESET" });
              }}
          >Reset</button>
      </>
    );
}

const ParentComponent: React.FC = (): JSX.Element => {
    const { dispatch } = useCountContext();
    return(
      <>
            <button onClick={e => {
                e.preventDefault();
                dispatch({ type: "INCREMENT" });
            }}
            >Increment</button>
            <button onClick={e => {
                e.preventDefault();
                dispatch({ type: "DECREMENT" });
            }}
            >Decrement</button>
            <ChildComponent />
      </>
    );
}

const App: React.FC = (): JSX.Element => {
    return (
        <StoreProvider>
            <ParentComponent />
        </StoreProvider>
    );
}

export default App;

App.tsx内ではAppとControleComponentに別れています
Appではstateの状態を表示とリセットボタンを配置
ControleComponentではカウント操作ボタンを配置
上記ようにコンポーネントを跨いだ状態でもステート管理ができるようになりました

ハマったポイント

実装していく上で、dispatchの型の取扱いでハマった。dispatchの型をどのように定義させるかというところ。調べたら同じようにハマっている人がいた。

React createContext issue in Typescript?

実際はすごく簡単で、空のオブジェクトに対してasを使って型を当てたらいいよってだけの話だった。

2020/05/02 追記&修正
Dispatchの型が用意されているのでそれを使用しよう。上記のasを当てるようなやり方よりも確実です。

import { Dispatch } from 'react';

...

interface StoreContextProps {
    state: StateType,
    dispatch: Dispatch<ActionType> => void;
}

まとめ

結論からしてReduxを入れるまでもない小規模なものに関してはContextAPIとReactHooksを使用した実装でもよいのではないだろうか?
Reduxを使用したくないケースでコンポーネントが2つ以上になり、propsによるバケツリレーを省きたい場合などはよいでしょう。

しかしながら規模が大きくなった場合はReduxを使用した方がよい。
Reduxを使用した場合、Redux-SagaやRedux-Thunkなど様々なライブラリがあるためさらに拡張が容易であること情報が成熟している点でみればReduxを選択すべきでしょう。

Githubにもソースを上げています
Redux-ContextAPI

関連記事