32
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

なるべくRedux使いたくないならHooksを学ぼう

Last updated at Posted at 2020-05-01

はじめに

Reactプロジェクトでのステート管理と言えばReduxを思い浮かべる方が多いのではないでしょうか。

Reduxは本当に素晴らしいライブラリであると思いますが、覚えるべきボイラープレートが多く、
元々シンプルだったReactがどんどん複雑になっているように感じることもあります。

Reactのバージョンアップによって追加されたhooksを利用することで、flux的なステート管理をReduxなしで実装することができます。

参考

React コンテキストを正しく使えば、ほとんどのプロジェクトで Redux は必要ありません。

関心を分割し、必要に応じて useReducer を使い、必要に応じて get/set コンテキストを分け、必要に応じて useMemo を使う -> Reactは最高。

今回は、以下の記事を参考にしながらhooksを利用したflux的なステート管理について学びます。
https://dev.to/ankitjena/ciao-redux-using-react-hooks-and-context-effectively-398j

従来のステート管理

かつてはコンストラクタ内に記述していたステートは、hooksの登場によって以下のように宣言し使うことができるようになりました。

useState

useStateの使い方はとてもシンプルで、ステートとそのステートを更新するための関数を作成します。

import { useState } from 'react'

const [name, setName] = useState("")

ログインコンポーネントを実装する

ここで、useStateを用いて簡素なログインのためのコンポーネントを記述してみます。
最終的には、flux的なステート管理を備えたログイン機能に育て上げる予定ですが、今はまだシンプルなステート管理のみとなっています。

import React, { useState } from 'react';
import axios from 'axios';
import LoadingButton from '../component/LoadingButton';

export default function Login() {
  const url = 'https://www.example.com';
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [emailError, setEmailError] = useState(false);
  const [passwordError, setPasswordError] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  async function handleSubmit() {
    setIsLoading(true);
    const res = await axios.post(url, { email, password });
    if (res.data.status === 'EMAIL_ERROR') {
      setEmailError(true);
    }
    if (res.data.status === 'PASSWORD_ERROR') {
      setPasswordError(true);
    }
    // イイ感じの処理
  }

  return (
    <div>
      <h1>ログイン</h1>
      <div>
        <input
          type="text"
          value={email}
          onChange={
            (e) => setEmail(e.target.value)
          }
        />
        <input
          type="password"
          value={password}
          onChange={
            (e) => setPassword(e.target.value)
          }
        />
        { isLoading
          ? <LoadingButton />
          : <button type="button" onClick={() => handleSubmit()}>Sign in</button> }
      </div>
    </div>
  );
}

これはとてもシンプルな実装ですが、複雑なステート管理や、複数のコンポーネントを跨いだステート管理には不向きです。

useReducerを使う

Reduxを使ったことがある方なら、useReducerの使い方には馴染みがあるかもしれません。
useReducerは引数を2つとり、新たなステートを返す関数です。

  • 第1引数:ステート
  • 第2引数:ステートを変化させるアクション

useReducerを使用することで、アクション単位でのステート更新を実現することができます。
今回は使っていませんが、例えばLOGINというアクションにusername``password``isLoggedIn``isErrorというステートを紐付けておけば、いちいちステートを一つづつ管理することなく、アクション単位でUIの状態を制御することができます。

ログインコンポーネントにuseReducerを追加する

ステートをuseReducerによって管理するよう書き換えたコードが以下です。
実行した際の挙動は、先のコードと変わりありません。

先にステートの初期値(initialState)とreducerを定義し、
userReducerに与えることで、Statedispatchを得ることができます。

stateを変更する際はdispatch関数を使います。

import React, { useReducer } from 'react';
import axios from 'axios';
import LoadingButton from '../component/LoadingButton';

const url = 'https://www.example.com';

const initialState = {
  user: {
    email: '',
    password: '',
  },
  errors: {
    email: false,
    password: false,
  },
  isLoading: false,
};

const reducer = (state, action) => {
  switch (action.type) {
    case 'CHANGE_VALUE':
      return {
        ...state,
        user: {
          ...state.user,
          [action.field]: action.data,
        },
      };
    case 'ERROR':
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.field]: true,
        },
      };
    case 'LOADING':
      return {
        ...state,
        isLoading: true,
      };
    default: return state;
  }
};

export default function Login() {
  const [state, dispatch] = useReducer(reducer, initialState);

  async function handleSubmit() {
    dispatch({ type: 'LOADING' });
    const res = await axios.post(url, state.user);
    if (res.data.status === 'EMAIL_ERROR') {
      dispatch({ type: 'ERROR', field: 'email' });
    }
    if (res.data.status === 'PASSWORD_ERROR') {
      dispatch({ type: 'ERROR', field: 'password' });
    }
  }

  return (
    <div>
      <h1>ログイン</h1>
      <div>
        <input
          type="text"
          value={state.user.email}
          onChange={
            (e) => dispatch({ type: 'CHANGE_VALUE', data: e.target.value, field: 'email' })
          }
        />
        <input
          type="password"
          value={state.user.password}
          onChange={
            (e) => dispatch({ type: 'CHANGE_VALUE', data: e.target.value, field: 'password' })
          }
        />
        { state.isLoading
          ? <LoadingButton />
          : <button type="button" onClick={() => handleSubmit()}>Sign in</button> }
      </div>
    </div>
  );
}

個別の関数を扱うのではなく、1つのreducerを宣言し、いくつかのアクションとそれに対応するストアの変更を定義します。useState を使用している間は、要件が大きくなると変数の数を見失うことがあるので、これは非常に便利です。

Viewとロジックを分離する

Reactの基本は、関心の分離です。
つまり、ロジックを担当するコンポーネントと、見た目を担当するコンポーネントを分けたいということです。
こうすることで、全体の見通しをよくしてバグを減らすだけでなく、コードの再利用性も高まります。

Custom Hooksを使う

ではuseLoginReducer.jsというファイルに、reducer部分を分割します。

hooks/useLoginReducer.js
import { useReducer } from 'react';

const initialState = {
  user: {
  // ...省略
};

const reducer = (state, action) => {
  switch (action.type) {
    // ...省略
};

export default function useLoginReducer() {
  const [store, dispatch] = useReducer(reducer, initialState);
  return [store, dispatch];
}

ログインコンポーネントで、インポートして使うことができるようになりました!

Login.js
import React from 'react';
import axios from 'axios';
import useLoginReducer from '../hooks/useLoginReducer';

export default function Login() {
  const [state, dispatch] = useLoginReducer();

  async function handleSubmit() {
    // ...省略
}

Globalなステートを扱う

Redux(flux)を扱う1番のメリットは、ステートをグローバルに管理し、どのコンポーネントからもアクセスできるようになる点です。

Hooksを利用する場合は、Contextを使用することで簡単にステートにアクセスすることができます。

Contextでは、

  • データを保存または初期化するProviderと、
  • データを読み込んだり更新したりするConsumerを宣言し利用します。

まずは先ほどの``LoginReducerContext`に統合します。

context.js
import React, { useReducer, useMemo } from 'react';

const globalContext = React.createContext();

const initialState = {
    // ...省略
};

const reducer = (state, action) => {
    // ...省略
};

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

  // Storeが変化した時だけリレンダリングするようuseMemoを使用
  const contextValue = useMemo(() => [store, dispatch],
    [store, dispatch]
  );

  return (
    <globalContext.Provider value={contextValue}>
      { children }
    </globalContext.Provider>
  );
};

export function useStore() {
  return React.useContext(globalContext);
}

App.jsで、コンポーネントをProviderで囲うと、store``dispatchを使用することができます。

App.js
import React from 'react';
import './App.css';
import Login from './container/login';
import { StoreProvider } from './hooks/context';

function App() {
  return (
    <StoreProvider>
      <Login />
    </StoreProvider>
  );
}

export default App;

さらに安全に、便利に使う

ここからが真骨頂ともいうべき特徴です。

Stateを更新する/更新しないコンポーネントで関心を分ける

ステートを更新しないコンポーネントにdispatchを与える必要はありませんし、ステートを参照しないコンポーネントにstoreを与える必要もありません。

この2つを分けるのは、簡単です。

dispatch用のコンテクストとstore用のコンテクストを分けて作り、それぞれexportして使用すればよいのです。

context.js
import React, { useReducer, useMemo } from 'react';

const storeContext = React.createContext();
const dispatchContext = React.createContext();

// ...省略

export const StoreProvider = ({ children }) => {
  const [store, dispatch] = useReducer(reducer, initialState);
  // Storeが変化した時だけリレンダリングするようuseMemoを使用
  const contextValue = useMemo(() => [store, dispatch],
    [store, dispatch],
  );

  return (
    <dispatchContext.Provider value={contextValue[1]}>
      <storeContext.Provider value={contextValue[0]}>
        { children }
      </storeContext.Provider>
    </dispatchContext.Provider>
  );
};

export function useStore() {
  return React.useContext(storeContext);
}
export function useDispatch() {
  return React.useContext(dispatchContext);
}

それぞれを別々にインポートして使用できるようになりました。

Login.js
import React from 'react';
import axios from 'axios';
import { useStore, useDispatch } from '../hooks/context';
import LoadingButton from '../component/LoadingButton';

export default function Login() {
  const state = useStore();
  const dispatch = useDispatch();

// ...省略

今回のログインコンポーネントでは、storedispatchも両方使っていますが、どちらか片方のみ必要な場合は必要な物だけインポートして使用することが可能です。

複数のStoreを扱う

全てのステートをたった一つのストアで管理するのは現実的ではありません。

複数のStoreに分けて管理しやすくすることが必要でしょう。

これを実現するために、initialStatereducer を引数にとり新しいstoreを返す関数を実装します。

makeContexts.js
import React, { createContext, useContext, useReducer } from 'react';

export default function makeStore(reducer, initialState) {
  const storeContext = createContext();
  const dispatchContext = createContext();

  const StoreProvider = ({ children }) => {
    const [store, dispatch] = useReducer(reducer, initialState);

    return (
      <dispatchContext.Provider value={dispatch}>
        <storeContext.Provider value={store}>
          {children}
        </storeContext.Provider>
      </dispatchContext.Provider>
    );
  };

  function useStore() {
    return useContext(storeContext);
  }
  function useDispatch() {
    return useContext(dispatchContext);
  }

  return [StoreProvider, useStore, useDispatch];
}

あとは、必要に応じてこの関数を元にStore(つまりプロバイダー、ストアの参照関数、ストアの更新関数)を作成します。

loginContext.js
import makeStore from './makeStore';

const initialState = {
  // ...省略
};

const reducer = (state, action) => {
  // ...省略
};

const [
  LoginProvider,
  useLoginStore,
  useLoginDispatch,
] = makeStore(reducer, initialState);

export { LoginProvider, useLoginStore, useLoginDispatch };

Storeを必要としているコンポーネントをProviderで囲って使用します。

App.js
import React from 'react';
import './App.css';
import Login from './container/login';
import { LoginProvider } from './hooks/loginContexts';

function App() {
  return (
    <LoginProvider>
      <Login />
    </LoginProvider>
  );
}

export default App;

囲まれたコンポーネントで、ストアを参照したり更新したりします。

Login.js
import React from 'react';
import axios from 'axios';
import { useLoginStore, useLoginDispatch } from '../hooks/loginContexts';
import LoadingButton from '../component/LoadingButton';

export default function Login() {
  const state = useLoginStore();
  const dispatch = useLoginDispatch();

  // ...省略

あとは必要に応じて、ストアを自由に作成することができます。

例えば以下のような構成で使うこともでき、どのコンポーネントがどのストアを参照しているか一覧できます。

function App() {
  return (
    <UserProvider>
        <Navbar />
        <ProductProvider>
            <Products />
        </ProductProvider>
    </UserProvider>
  );
}

まとめ

React標準のHooksAPIを利用することで、Reduxを使わずにステート管理を実装することができました。

ほとんどの場合、Hooksで十分なのでは?と思います。

32
31
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
31

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?