独学エンジニアのメモ帳

得た知識のアウトプットとか日常のメモとか。ゆるくやる。

React hooksで非同期通信のあるグローバルストア管理

ちょっとしっくりくるタイトルじゃない気がする。語彙力。

やりたいこと

例えば、ログインのよくある流れを考えてみる。
ストアの動きとして
1. ローディング状態をtrueにしてローダーを表示(dispatch)
2. ログイン通信
3. 取得したユーザデータをストアに保存(dispatch)
という動きが考えられる。

Reduxではredux-thunkなどを使ってaction内からdispatchすることで実現できたが、
Reduxを使わずにReact Hooksで実装する場合どうしたら良いか分からなかったので調べながらやってみた。

ストアの作成が趣旨なので、APIでのログイン処理の中身には触れません。

結論

ContextAPI + カスタムフック で対応できる

作成例

今回は一つですが、情報に応じてファイル分割も簡単にできそう。
ディレクトリは一旦全て同じ階層にあるという想定で。

ストア(reducer)の作成

まずは普通にreducerとactionを作る。

[actions.js]

export const AUTH_START = 'AUTH_START';
export const AUTH_SUCCESS = 'AUTH_SUCCESS';
export const AUTH_FAIL = 'AUTH_FAIL';

export const authStart = () => {
  return {
    type: AUTH_START,
  }
}

// 引数はログインの仕様に合わせて
export const authSuccess = (id, email) => {
  return {
    type: AUTH_SUCCESS,
    id,
    email,
  }
}

export const authFail = (error) => {
  return {
    type: AUTH_FAIL,
    error
  }
}

[reducer.js]

import { AUTH_START, AUTH_SUCCESS, AUTH_FAIL } from './actions';

export const initialState = {
  id: '',
  email: '',
  error: null,
  loading: false,
}

const reducer = (state = initialState, action) => {
  switch (action.type) {
  case AUTH_START:
    return { ...state, loading: true };
  case AUTH_SUCCESS:
      return {
        ...state,
        id: action.id,
        email: action.email,
        error: null,
        loading: false
      };
  case AUTH_FAIL:
    return { ...state, error: action.error, loading: false };

  default:
    return state
  }
}

export default reducer;

普通のreducer。

Contextの作成

[context.js]

import React, {useReducer} from "react";
import reducer, { initialState } from "./reducer";

export const Context = React.createContext({});

export const Provider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return <Context.Provider value={{state, dispatch}}>{children}</Context.Provider>;
};

[App.js]

import { Provider } from "./store/auth/context";

const App = () => {
  return (
    <Provider>
      <ChildComponents />
    </Provider>
  );
};

ChildComponents以下でuseContext(Context)することで、
Providerのvalueに渡した値がchildrenの中で使えるようになる。

グローバルストアは一応ここまでで完成。

カスタムフックを作成

今回やりたいことは、非同期通信の結果を待ってdispatchすることので、このままだとできない。
そのため、カスタムフックを作成する。
use~で分かりやすい名前を付ける。

[useAuth.js]

impor { useCallback } from "react";
import { authStart, authSuccess, authFail } from "./actions";
import axios from "axios";

export const useAuth = () => {
  const {state, dispatch} = useContext(Context);

  const login = useCallback((email, password) => {
    // ローディングを走らせる
    dispatch(authStart());

    // ログイン通信
    axios.post('/api/login', {
      email,
      password,
    }).then(res => {
      console.log(res);
      // 成功したらログイン情報をストアに保存
      dispatch(authSuccess(res.data.user.id, res.data.user.email));
    }).catch(err => {
      // 失敗したらエラーをストアに保存
      console.log(err.response);
      dispatch(authFail(err));
    })
  }, [dispatch, authStart, authSuccess, authFail]);

  // ここでreturnしたものを各コンポーネントで使う
  return {state, login}
};

カスタムフック内でContextを呼び出し、
loginメソッド内で複数回dispatchしています。
コンポーネントではこのフックを介してストアにアクセスします。

使ってみる

[Login.js]

import React, { FormEvent, useState } from "react";

import { useAuth } from "./useAuth";

const Login = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const { state, login } = useAuth();

  const loginHandler = (e) => {
    e.preventDefault();
    login(email, password);
  };

  let loader = null;
  if (state.loading) loader = <div>loading...</div>;

  return (
    <React.Fragment>
      {loader}
      <div>
        <h2>ユーザー情報</h2>
        <p>id: {state.id}</p>
        <p>email: {state.email}</p>
      </div>

      <form onSubmit={loginHandler}>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={e => setPassword(e.target.value)}
        />
        <button type="submit">Login</button>
      </form>
    </React.Fragment>
  );
};

export default Login;

ログイン処理中に「loading...」が表示され、
ログイン後にユーザー情報が表示されれば成功。

まとめ

カスタムフックのおかげで記述少なめでグローバルストアが定義できた。
Reduxの導入はいろいろ手間がかかるので、小さい規模なら十分というか、むしろこっちが良い感じ。

ただ公式にも書いてあったけど、言ってしまえばただの関数なので、どうにでも書けてしまう感はある。
調べてもこれという正解がいまいち分からないので、複雑なものをReact Hooksでやろうとしたらまだいろいろ躓きそう。
今後どうなるかは分からないけど、Reduxの方が今のところ参考になる記事も多い気がするので、変なやり方を覚えたりする心配は少なそう。
規模が大きかったり複雑な場合は大人しくreduxに身を委ねるのが良いのかもと思ったり。