easy going tech

エンジニアリング、プログラミング学習のアウトプット

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を掲載しておきます。

https://github.com/yuma-matsui/tasting_note_front/pull/388

自作サービスの規模であれば大きな問題にはなりませんが、これから仕事をする上でパフォーマンスを意識することは重要だと思い修正を行いました。 createContexを使用したステート管理は気をつけるべきことが多いため、状態管理ライブラリを使用することで、こういったことを意識せずにグローバルステートを使用するのが一番だと思います。 それをいってしまってはこの記事の立つ瀬がないのですが...。