useContext + useReducer の使いどころ

@Panda_Program

TR;DR

  • useContext は、階層の深いコンポーネントに state を渡す場面で使うと良い
  • useReducer は、state の変更パターンが多い場面で使うと良い
  • useContext + useReducer は、state を使うコンポーネントの階層が深い上に、前回の state を元に新しい状態を作る場面で使うと良い

useContextだけを使うケース

useContext は React の組み込みの Hooks の1つです。Provider でラップしたコンポーネントのツリーのどこからでも、同一の Context Object を参照できるようにする Hook です。

useContext は Context を通じて子や孫以下のコンポーネントで同一の JS オブジェクトを呼び出せる Hook です。これにより props のバケツリレー (Props Drilling)を避けられる利点があります。

なお、useContext は、実は公式ドキュメントで紹介されている「基本的な Hooks」の1つです。他の基本的な Hooks は useStateuseEffect なので、useContextも基礎的なものとされていることがわかります。

useContextの使い方

family tree

useContextの使い方を見てみましょう。下記の例は3階層ですが、このようにコンポーネントの階層が深い場合に活用します。

変数familyTreeを Context Object とし、この変数に格納されている子供と孫の名前、年齢を子コンポーネント、孫コンポーネントでそれぞれ表示します。

const familyTree = {
  child: { name: 'Smith', age: 28 },
  grandchild: { name: 'Alice', age: 1 },
} as const

const FamilyTreeContext = React.createContext<typeof familyTree>(null)

const Parent: React.VFC = () => (
  <FamilyTreeContext.Provider value={familyTree}>
    <Child />
  </FamilyTreeContext.Provider>
)

const Child: React.VFC = () => {
  const { child } = useContext(FamilyTreeContext)

  return (
    <main>
      <div>
        <p>name: {child.name}</p>
        <p>age: {child.age}</p>
      </div>

      <GrandChild />
    </main>
  )
}

const GrandChild: React.VFC = () => {
  const { grandchild } = useContext(FamilyTreeContext)

  return (
    <div>
      <p>name: {grandchild.name}</p>
      <p>age: {grandchild.age}</p>
    </div>
  )
}

なお、Context Object は JS のオブジェクトなので、もちろん値以外に関数も渡せます。

useContextを使う前にコンポーネントやContextにまとめる対象を見直す

(本節は追記です)

Prop Drilling を避けたいという理由だけのuseContextの乱用を戒める意見もあります(「props のバケツリレーって何が悪いんだっけ」)。記事内の以下の文の通りだと思います。

Context は横断的な関心事(めっちゃいろんなコンポーネントで使うとか)をメインの動機にすべきで、「階層が深いから」は本質的ではないと思う。

また、記事の最後に React コアチームの @dan_abramov 氏のツイートが引用されています。

「Facebook のルートには約30の Context があるが問題と思っていない。ショートカットキー、ルーティング、データ、モーダル層、フォーカス管理など、異なる範囲をカバーしているから」とのことです。

Context を使う数に上限はないですが、それぞれの Context に渡す値は「文脈」という意味の通り適切に分割することが重要です。

useReducerだけを使うケース

reducer

useReducer は、useStateの代わりとなる Hook です。

複数の値にまたがるロジックがある場合や、前の state に基づいて次の state を決めるタイムトラベルのような機能を実現するために使われます。

useReducer は公式ドキュメントで「追加の Hooks」の1つとされています。ただ、useContextよりuseReducerの方が単体で使われている印象があります。

useReducerの使い方

useReducerの使い方を見てみましょう。以下ではカウンターを作成し、incrementdecrementresetという action を設定しています。

type State = { count: number }

const initialState = { count: 0 }

type Action = 
  | { type: 'increment' } 
  | { type: 'decrement' }
  | { type: 'reset' }

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return initialState
    default:
      return state
  }
}

const Counter: React.FC = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'increment' })}>reset</button>
    </>
  )
}

useReducerに関連するactionをCustom Hooksにまとめる

Custom Hook を作成することで、dispatchを利用した action をあらかじめ作成しておく方法もあります。

// reducer 等は省略

const useCounter = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  const increment = () => dispatch({ type: 'increment' })
  const decrement = () => dispatch({ type: 'decrement' })
  const reset = () => dispatch({ type: 'reset' })

  return { state, increment, decrement, reset } as const
}

const Counter: React.VFC = () => {
  const { state, increment, decrement, reset } = useCounter()

  return (
    <>
      Count: {state.count}
      <button onClick={increment}>-</button>
      <button onClick={decrement}>+</button>
      <button onClick={reset}>reset</button>
    </>
  )
}

useCounterを呼び出せばどのコンポーネントからでもカウンターの機能を呼び出せます。

また、@sonatard 氏の記事で配列を使った stack の実装方法をuseStateからuseReducerにリファクタリングをする例が紹介されています。 記事内でuseStateではなくuseReducerを利用する利点が簡潔に語られています。

配列やオブジェクトの一部を操作する場合のように前回の状態に依存した更新処理をする場合には useState の代わりに useReducer を利用することで、より簡潔に記述することができるようになります。

React Hooksとカスタムフックが実現する世界 - ロジックの分離と再利用性の向上(@sonatard)

リファクタリングの思考過程が丁寧に記述されており、とても参考になります。

useContextとuseReducerの両方を使うケース

useContextuseReducerの両方を使うケースは、部分的に使う場合とアプリケーション全体で使う場合があります。

部分的なコンポーネントツリーで使う

ツリーの一部の葉が赤色になっている

少し状態管理がややこしくなった場合、特定のコンポーネントとその子以下のコンポーネントでuseContextuseReducerを組み合わせて使うのは一般的でしょう。

私は本業で弁護士ドットコムライブラリー というサービスを開発しているので、そこでの使用例を紹介します。

これは法律書籍の読み放題サービスであるため、トップページに書籍の検索欄を設置しています。また詳細な条件を指定する場合は、各条件を入力するフォームをモーダル上に表示します。

この検索欄 Inputコンポーネントとモーダル + 検索条件 Conditionコンポーネントで検索の詳細な条件指定に対応しています。

これらを合わせてSearchInputコンポーネントとし、検索条件の状態管理にuseContextuseReducerを組み合わせて使っています。

弁護士ドットコムライブラリーのトップページ

詳細な検索条件では、キーワードを変更できるのはもちろんのこと、単行本もしくは雑誌から検索したり、出版年や出版社を指定できます。

モーダルと検索条件

条件を集約したいため、下記のState型を一部に持つコンテキストオブジェクトを作成します。なお、具体的な値を省略したりコメントを追加するなどコードは一部改変をしています。

context.tsx
import {AliasName, AliasLabel, PublisherName, TargetName, TargetLabel} from './types'

type SearchConditionConfig = {
  // 省略
}

// コンテキストオブジェクトの state の型
type State = {
  aliases: SearchConditionConfig['aliases']
  target: SearchConditionConfig['targets']['name']
  releaseYear: SearchConditionConfig['releaseYears'][number]
  publishers: SearchConditionConfig['publishers']
}

// type Action, initialState は省略

const initialState = {
  // 省略
}

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

export const SearchConditionContext = createContext<State | Action>(initialState)

SearchConditionContext.displayName = 'SearchCondition'

// Context Object の Provider(提供側)コンポーネントを返す
const SearchConditionProvider: React.FC = (props) => {
  const [state, dispatch] = useReducer(reducer, initialState)

  // 出版社のチェックボックスをつけたり外したりする
  const togglePublisherByName = (name: PublisherName) => dispatch({ type: 'TOGGLE_PUBLISHER', name })
  // 全ての出版社を選択する
  const selectAllPublisher = () => dispatch({ type: 'SELECT_ALL_PUBLISHER' })
  // 全ての出版社のチェックを外す
  const clearAllPublisher = () => dispatch({ type: 'CLEAR_ALL_PUBLISHER' })

  const value = useMemo(
    () => ({
      state,
      togglePublisherByName,
      selectAllPublisher,
      clearAllPublisher,
    }),
    [state]
  )

  return <SearchConditionContext.Provider value={value} {...props} />
}

// Context Object を子、孫コンポーネントから呼び出すための Custom Hook
export const useSearchCondition = () => {
  const context = useContext(SearchConditionContext)

  if (typeof context === 'undefined') {
    throw new Error('useSearchCondition must be within a SearchConditionProvider')
  }

  return context
}

// 検索条件の state と更新のための action を提供するためのコンポーネント
export const ManagedSearchConditionContext: React.FC = (props) => (
  <SearchConditionProvider>{props.children}</SearchConditionProvider>
)

ManagedSearchConditionContextの書き方は Next.js Commerce の UIContext を参考にしています。

SearchInputコンポーネントをManagedSearchConditionContextでラップし、Context Object(state と action)を子、孫コンポーネントから呼び出せるようにします。

SearchInput.tsx
export type ContainerProps = {
  // 省略
}

type Props = {
  onSubmit: (e: React.SubmitEvent<HTMLFormElement>) => void
  keyword: Keyword
  isOpen: boolean
  // 省略
}

const Component: React.FC<Props> = (props) => (
  <form onSubmit={props.onSubmit}>
    <Input keyword={props.keyword} />

    <Modal open={props.isOpen}>
      <Condition keyword={props.keyword} />
    </Modal>
  </form>
)

const Container: React.FC<ContainerProps> = (props) => {
  // 省略
  
  return (
    <Component
      onSubmit={handleSubmit}
      keyword={nextKeyword}
      isOpen={isOpen}
      {/* 省略  */}
    />
  )
}

const WithContext: React.FC<ContainerProps> = (props) => (
  <ManagedSearchConditionContext>
    <Container {...props} />
  </ManagedSearchConditionContext>
)

WithContext.displayName = 'SearchInput'

export default WithContext

上記のように記述することで、Conditionコンポーネントの中でuseSearchConditionを呼び出せます。

なお、このように部分的なコンポーネントツリーでuseContextuseReducerを組み合わせて使うと、Storybook でSearchInputコンポーネントを表示する際SearchInput単体で表示ができます。

つまり、Storybook のコンポーネントに検索条件コンテキストのProviderを渡すデコレータを用意する必要がなくなるのです。

index.stories.tsx
import { Meta, Story } from '@storybook/react'

import SearchInput, { ContainerProps as Props } from './SearchInput'

export default {
  title: 'features/search/SearchInput',
} as Meta<Props>

const Template: Story<Props> = ({ ...args }) => <SearchInput {...args} />

export const Default = Template.bind({})
Default.args = { ... }

アプリケーション全体で使う

ツリーの全体が赤色になっている

アプリケーション全体で使える場合もあります。ただし、レンダリングパフォーマンスの向上のために状態の参照・更新を分けるといった最適化をした方が良いとされています。

  • React Context を用いた簡易 Store
    • useContext+useState で参照系・更新系を作成する例。Redux の代わりとなる簡易 Store になると記述されている
  • React.Context で作る GlobalUI Custom Hooks
    • useContextuseReducerで「通知」というグローバルなコンポーネントのメッセージと表示制御をする例。参照・更新を分けている
  • Next.js Commerce
    • Next.js を開発している Vercel 社の EC サンプルアプリ(「部分的な利用」で紹介)。useContextuseReducer でサイドバーの開閉などグローバルな UI の状態管理をしている。参照・更新は分けていない

いずれも Redux は使わないことがモチベーションにありそうです。

いろいろ考えたくない人はReduxの方が手軽かもしれない

React Hooks が登場して間もない2018年末頃、useContext + useReducer は Redux の代替になるのか盛んに議論されました。 しかし、現在の状況に照らし合わせると Redux には豊かなエコシステム が存在することが強みであるため、完全には置き換えられていません。

Redux を採用している現場は少なくなく、日本語のドキュメントも豊富であるため、初めて React を触るなど色々考えたくない人はuseContext+useReducerを使うより素直に Redux を導入した方が手軽かもしれません。

実際、@kazuma1989 氏の「ぼくのかんがえたさいきょうの useState + useContext よりも Redux のほうが大抵勝っている」 という記事で、Redux の優れている点が解説されています。

最初から最適化を考える前に(早すぎる最適化) Redux に慣れてきた後に、reselectで返り値をメモ化したり、reducer 内でimmerといった便利なライブラリを導入してもいいかもしれません。

ただ、個人的には Redux は好きですが、Redux を導入するとその豊富なエコシステムゆえに様々な意思決定をしなければならないのも事実です。このため Redux 避けたい気持ちも理解できます。

具体的には、非同期処理を useEffect で行うか、またはredux-thunkに任せるのか。immer は入れるのか、あるいは入れないのか。これらを自分で準備するなら、いっそのこと Redux Toolkit を入れるのかなど...。

関連記事: Redux Toolkitの構成技術を触ってみた(reselect・Immer・Redux Thunk)

React公式はuseContextを最適化したHook「useContextSelector」を実装しようとしている

なぜuseContextとレンダリングパフォーマンス向上の話は切っても切り離せないのでしょうか。それはuseContext + useReducerで状態管理をするとき、dispatchで action を発行して状態を更新すれば、新しい state が生成されてuseContextの返り値が毎回変化するからです。

これは、reducer の役割が action と古い state を受け取り、新しい state を作成することであると考えるとごく自然な結果です。

しかし、reducer の性質により不要な再レンダリングが実行されるのは React にとって問題です。この問題を解決するために、useContextSelectorという Hook な API として実験的に実装されています([Experiment] Context Selectors #20646)。

useContextSelectorはまだ安定的な Hooks ではないため、同じ目的を達成するためには @dai_shi 氏の use-context-selectorQiita)というライブラリがその代替になります。これらの Hooks はどちらも RFC #119 を元に実装されています。

まとめ

他に、サーバーからのレスポンスを格納するだけなら SWR や React Query といった別の手段もあります。

関連記事: useSWRはAPIからデータ取得をする快適なReact Hooksだと伝えたい

技術選択の意思決定に一定の答えはありません。チームのメンバーのスキルやアプリケーションの性質や規模の大小などに応じて、ケースバイケースで決めていくことが重要です。

Happy Coding 🎉

パンダのイラスト
パンダ

記事が面白いと思ったらツイートやはてブをお願いします!皆さんの感想が執筆のモチベーションになります。最後まで読んでくれてありがとう。

  • Share on Hatena
  • Share on Twitter
  • Share on Line
  • Copy to clipboard