createContextを使用したグローバルステート管理の注意点⚠️
はじめに
7月3日にTasting Noteというサービスをリリースしまして、こちらのフロントエンドをReactで開発をしたのですが、グローバルステートの管理方法に問題があったので、その問題点と改善方法についてまとめたいと思います。
ステートは参照系と更新系で分けて管理しよう
まずは変更前のコードを記載します。
RequestingContext.ts
import { Dispatch, SetStateAction, createContext } from 'react' const RequestingContext = createContext<{ requesting: boolean setRequesting: Dispatch<SetStateAction<boolean>> }>({ requesting: false, setRequesting: () => {} }) export default RequestingContext
RequestingProvider.tsx
import { FC, useMemo, useState, ReactNode } from 'react' import { RequestingContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) const requestingState = useMemo(() => ({ requesting, setRequesting }), [requesting]) return <RequestingContext.Provider value={requestingState}>{children}</RequestingContext.Provider> } export default RequestingProvider
RequestingProviderはAPIリクエスト中の状態管理をしており、APIリクエストを行うロジックの中でsetRequestingを呼び出すことで、リクエスト中の画面にLoadingコンポーネントを表示するといった使い方をしています。
問題点
requestingが更新された場合にsetRequestingしか呼び出していないコンポーネントも再レンダリングされてしまうことです。
例えば複数コンポーネントでAPI通信を行う処理があり、その処理の中でsetRequestingを使用したいとします。setRequestingのみ必要なので、const { setRequesting } = useContext(RequestingContext)
といった具合にsetRequestingのみを取り出しますが、この場合、requestingが更新されたタイミングでrequestingを呼び出していないにも関わらず、setRequestingを呼び出しているすべてのコンポーネントが再レンダリングされてしまいます。
理由は同じコンテキストでrequestingとsetRequestingを管理しているからです。
Reactコンポーネントが再レンダリングされる条件は主に以下です。
前述の例ではrequesting(ステート)が更新されたため、同じコンテキストで管理しているsetRequestingを呼び出しているコンポーネントも再レンダリングの対象になったということです。
不要な再レンダリングを起こすとサービスの規模が大きくなった場合にパフォーマンスへ大きな影響を及ぼすため、可能な限り避けるべきです。
解決方法
requesting、setRequestingを別のコンテキストで管理することで解決します。 先に変更後のコードを記載します。
RequestingContext.ts
import { createContext } from 'react' const RequestingContext = createContext<boolean>(false) export default RequestingContext
RequestingDispatchContext.ts
import { Dispatch, SetStateAction, createContext } from 'react' const RequestingDispatchContext = createContext<Dispatch<SetStateAction<boolean>>>(() => { throw Error('No default value!') }) export default RequestingDispatchContext
RequestingProvider.tsx
import { FC, useState, ReactNode } from 'react' import { RequestingContext, RequestingDispatchContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) return ( <RequestingContext.Provider value={requesting}> <RequestingDispatchContext.Provider value={setRequesting}> {children} </RequestingDispatchContext.Provider> </RequestingContext.Provider> ) } export default RequestingProvider
requestingをRequestingContext、setRequestingをRequestingDispatchContextで管理するように変更しています。 こうすることでsetRequestingしか呼び出していないコンポーネントの不要な再レンダリングを防げます。
補足
例えばsetRequestingを呼びだす関数をRequestingProvider内で定義して、その関数をステートとして管理するケースを考えます。
RequestingDispatchContext.ts
import { Dispatch, SetStateAction, createContext } from 'react' const RequestingDispatchContext = createContext<(function: () => void) => void>((() => {}) => { throw Error('No default value!') }) export default RequestingDispatchContext
RequestingProvider.tsx
import { FC, useState, ReactNode } from 'react' import { RequestingContext, RequestingDispatchContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) const startRequesting = (function: () => void) => { setRequesting(true) try { function() } catch { throw Error('Something went wrong.') } finally { setRequesting(false) } } return ( <RequestingContext.Provider value={requesting}> <RequestingDispatchContext.Provider value={startRequesting}> {children} </RequestingDispatchContext.Provider> </RequestingContext.Provider> ) } export default RequestingProvider
上記コードではrequestingが更新されるタイミングでstartRequestingが再生成されてしまうため、コンテキストを分けているにも関わらず、requestingが更新されるとstartRequestingを使用しているコンポーネントも再レンダリングの対象になってしまいます。
この問題の解決方法はuseCallbackを使用してstartRequestingの再生成を防ぐことです。
RequestingProvider.tsx
import { FC, useState, useCallback, ReactNode } from 'react' import { RequestingContext, RequestingDispatchContext } from '../contexts' const RequestingProvider: FC<{ children: ReactNode }> = ({ children }) => { const [requesting, setRequesting] = useState(false) const startRequesting = useCallback((function: () => void) => { setRequesting(true) try { function() } catch { throw Error('Something went wrong.') } finally { setRequesting(false) } }, []) return ( <RequestingContext.Provider value={requesting}> <RequestingDispatchContext.Provider value={startRequesting}> {children} </RequestingDispatchContext.Provider> </RequestingContext.Provider> ) } export default RequestingProvider
また、RequestingDispatchContextで管理する関数が複数になった場合はRequestingDispatchProviderへ渡すvalueがオブジェクトになるかと思いますが、この場合も対象のオブジェクトをuseMemoを使用して再生成を防がないと同様のことが発生します。
最後に
修正を行なった際のPRを掲載しておきます。
自作サービスの規模であれば大きな問題にはなりませんが、これから仕事をする上でパフォーマンスを意識することは重要だと思い修正を行いました。 createContexを使用したステート管理は気をつけるべきことが多いため、状態管理ライブラリを使用することで、こういったことを意識せずにグローバルステートを使用するのが一番だと思います。 それをいってしまってはこの記事の立つ瀬がないのですが...。