useContext + useReducer の使いどころ
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 は useState
、useEffect
なので、useContext
も基礎的なものとされていることがわかります。
useContextの使い方
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だけを使うケース
useReducer は、useState
の代わりとなる Hook です。
複数の値にまたがるロジックがある場合や、前の state に基づいて次の state を決めるタイムトラベルのような機能を実現するために使われます。
useReducer
は公式ドキュメントで「追加の Hooks」の1つとされています。ただ、useContext
よりuseReducer
の方が単体で使われている印象があります。
useReducerの使い方
useReducer
の使い方を見てみましょう。以下ではカウンターを作成し、increment
、decrement
、reset
という 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の両方を使うケース
useContext
とuseReducer
の両方を使うケースは、部分的に使う場合とアプリケーション全体で使う場合があります。
部分的なコンポーネントツリーで使う
少し状態管理がややこしくなった場合、特定のコンポーネントとその子以下のコンポーネントでuseContext
とuseReducer
を組み合わせて使うのは一般的でしょう。
私は本業で弁護士ドットコムライブラリー というサービスを開発しているので、そこでの使用例を紹介します。
これは法律書籍の読み放題サービスであるため、トップページに書籍の検索欄を設置しています。また詳細な条件を指定する場合は、各条件を入力するフォームをモーダル上に表示します。
この検索欄 Input
コンポーネントとモーダル + 検索条件 Condition
コンポーネントで検索の詳細な条件指定に対応しています。
これらを合わせてSearchInput
コンポーネントとし、検索条件の状態管理にuseContext
とuseReducer
を組み合わせて使っています。
詳細な検索条件では、キーワードを変更できるのはもちろんのこと、単行本もしくは雑誌から検索したり、出版年や出版社を指定できます。
条件を集約したいため、下記のState
型を一部に持つコンテキストオブジェクトを作成します。なお、具体的な値を省略したりコメントを追加するなどコードは一部改変をしています。
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)を子、孫コンポーネントから呼び出せるようにします。
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
を呼び出せます。
なお、このように部分的なコンポーネントツリーでuseContext
とuseReducer
を組み合わせて使うと、Storybook でSearchInput
コンポーネントを表示する際SearchInput
単体で表示ができます。
つまり、Storybook のコンポーネントに検索条件コンテキストのProvider
を渡すデコレータを用意する必要がなくなるのです。
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
useContext
とuseReducer
で「通知」というグローバルなコンポーネントのメッセージと表示制御をする例。参照・更新を分けている
- Next.js Commerce
- Next.js を開発している Vercel 社の EC サンプルアプリ(「部分的な利用」で紹介)。
useContext
とuseReducer
でサイドバーの開閉などグローバルな UI の状態管理をしている。参照・更新は分けていない
- Next.js を開発している Vercel 社の EC サンプルアプリ(「部分的な利用」で紹介)。
いずれも 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-selector (Qiita)というライブラリがその代替になります。これらの Hooks はどちらも RFC #119 を元に実装されています。
まとめ
他に、サーバーからのレスポンスを格納するだけなら SWR や React Query といった別の手段もあります。
関連記事: useSWRはAPIからデータ取得をする快適なReact Hooksだと伝えたい
技術選択の意思決定に一定の答えはありません。チームのメンバーのスキルやアプリケーションの性質や規模の大小などに応じて、ケースバイケースで決めていくことが重要です。
Happy Coding 🎉