はじめに
Reactプロジェクトでのステート管理と言えばReduxを思い浮かべる方が多いのではないでしょうか。
Reduxは本当に素晴らしいライブラリであると思いますが、覚えるべきボイラープレートが多く、
元々シンプルだったReactがどんどん複雑になっているように感じることもあります。
Reactのバージョンアップによって追加されたhooksを利用することで、flux的なステート管理をReduxなしで実装することができます。
参考
Most projects don’t need Redux if they use React Context correctly
— Andrew Jones (@ajones_codes) April 21, 2020
So many problems b/c people try to put all their state in one context
If you break them up by concern instead, use useReducer, separate get/set contexts as needed, useMemo as needed -> React Context is golden
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
に与えることで、State
とdispatch
を得ることができます。
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
部分を分割します。
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];
}
ログインコンポーネントで、インポートして使うことができるようになりました!
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
を宣言し利用します。
まずは先ほどの``LoginReducerを
Context`に統合します。
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
を使用することができます。
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
して使用すればよいのです。
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);
}
それぞれを別々にインポートして使用できるようになりました。
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();
// ...省略
今回のログインコンポーネントでは、store
もdispatch
も両方使っていますが、どちらか片方のみ必要な場合は必要な物だけインポートして使用することが可能です。
複数のStore
を扱う
全てのステートをたった一つのストアで管理するのは現実的ではありません。
複数のStoreに分けて管理しやすくすることが必要でしょう。
これを実現するために、initialState
と reducer
を引数にとり新しいstore
を返す関数を実装します。
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
(つまりプロバイダー、ストアの参照関数、ストアの更新関数)を作成します。
import makeStore from './makeStore';
const initialState = {
// ...省略
};
const reducer = (state, action) => {
// ...省略
};
const [
LoginProvider,
useLoginStore,
useLoginDispatch,
] = makeStore(reducer, initialState);
export { LoginProvider, useLoginStore, useLoginDispatch };
Storeを必要としているコンポーネントをProvider
で囲って使用します。
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;
囲まれたコンポーネントで、ストアを参照したり更新したりします。
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で十分なのでは?と思います。