概要
React には Context という Hook の仕組みがあるのですが、あまり使ってない人も多いようなので、解説していこうと思います。
Context の使う目的は基本的には props リレーを防ぐためとまとめていうことができますが、ライブラリ層とビジネスロジック層を分けたている場合に state をカプセル化したり公開したりすることにも使えまして、これが大変便利なのでこの辺も紹介していこうと思います。
順に説明します。
1) props リレーを防ぐ Context
一番基本的な使い方です。
state やコールバック等を下位コンポーネントの深い層にまで渡すとなると props のリレーが生まれて冗長な記述が連続して嫌な気持ちになりますね。
そこで Context の出番です。
function App() {
const user: User = { name: "John Doe", age: 30 }
// user 渡す
return <Parent user={user} />
}
// user を下位にリレーする(自分では使わない)
function Parent(props: { user: User }) {
// user 渡す
return <Child user={props.user} />
}
// user を下位にリレーする(自分では使わない)
function Child(props: { user: User }) {
// user 渡す
return <Grandchild user={props.user} />
}
// user 受け取って使う
function Grandchild(props: { user: User }) {
return (
<div>
<h1>Name: {props.user.name}</h1>
<p>Age: {props.user.age}</p>
</div>
)
}
↓ 下記のようにスッキリかけます。
const UserContext = createContext<{ user: User }>({ user: { name: '', age: 0 } })
function App() {
const user = { name: "John Doe", age: 30 }
return (
// user 渡す
<UserContext.Provider value={{ user }}>
<Parent />
</UserContext.Provider>
)
}
// user の受け渡し不要
function Parent() {
return <Child />
}
// user の受け渡し不要
function Child() {
return <Grandchild />
}
function Grandchild() {
// 使うところで受け取る
const { user } = useContext(UserContext)
return (
<div>
<h1>Name: {user.name}</h1>
<p>Age: {user.age}</p>
</div>
)
}
中間層で無駄なリレーがなくなりましたね。
これなら将来 user のように受け渡す値の仕様が変わったら際にも、中間層の無駄な修正が発生しません。
メンテナビリティが高まります。
2) ライブラリ層でカプセル化
ソースの可読性を上げるコツとして、ビジネス要件から来る『ビジネスロジック層』と、システム的な便宜的な理由や全体統一の観点から来る『ライブラリ層』を分離することが重要です。
ライブラリ層が、親コンポーネントとその中で使うべき部品コンポーネントを両方提供することはよくありますね。
たとえば <App></App>
と共にその中で使うべき <CommonButton />
などのように。
その際にライブラリ層、つまり <App></App>
と <CommonButton />
との間でやり取りする state (や コールバック) が、ビジネス層からは見えないようにしたいときには Context を使います。
// 非公開のコンポーネント
// ※ ここを露出 (export) させないことで、間に挟まるビジネスロジック層ではアクセスができません。
// ビジネスロジック層では使ってはいけないのだと
// (ライブラリ層の都合で仕様が変わるものであるのだと)明示できるのです。
const AppContext = createContext<{
log: (message: string) => void
}>({
log: () => {},
})
// 外部公開する『親』コンポーネント
// ※ Context の値を注入する
export function App(props: { children: ReactNode }) {
const user: User = { name: "John Doe", age: 30 }
const log = (message: string) => {
console.log(`[${user.name}]: ${message}`)
}
return (
<AppContext.Provider value={{ log }}>
<div>
<h1>Framework App</h1>
{props.children}
</div>
</AppContext.Provider>
)
}
// 外部公開する『部品』コンポーネント
// ※ Context の値を使う
export function CommonButton(props: { label: string; onClick: () => void }) {
const { log } = useContext(AppContext)
return (
<button onClick={() => {
log(`'${props.label}' Button was clicked`)
props.onClick()
}}>
{props.label}
</button>
)
}
function UserApp() {
// App と CommonButton 間で state やコールバックがやりとりされているが、
// ビジネスロジック層からは見えない。
return (
<App>
<CommonButton />
</App>
)
}
3) ライブラリ層からリアクティブな値や関数を供給する
ライブラリ層で統一して使うべき state やリアクティブな関数を用意したいことがありますね。
たとえば、前述のログを出すための log
など。
基本的な実装方法は前述の例と同じですが、Context を公開する点と公開の際の粒度の考え方が大きく異なります。
どういうことかというと、Context を供給する側(ライブラリ層)と使う側(ビジネスロジック層)の層が異なる場合、どれくらいの頻度で値が更新されるか(=再レンダリングされるか)がカプセル化されて隠されてしまいます(本来隠されていても良いはずです)。
使うが側(ビジネスロジック層)にしてみると、const { aaa } = useContext(XxxContext)
のように XxxContext
が供給してくれる値のうち aaa
しか使ってないわけですが、実際には XxxContext
が供給するいずれの値 bbb
や ccc
が更新されたときでも use しているビジネスロジック層のコンポーネントは再レンダリングされることになります。これは想像以上にパフォーマンス問題や謎のチラつき問題を引き起こすかもしれないのです。
つまり、全部入りの Context を用意して使いたいものだけ、const { aaa } =
で使ってね、という設計は最適とはいえないのです。
過剰にレンダリングされる危険があるのです。
なので、ライブラリ層の Context の設計では再レンダリングが最小となるような単位(=更新サイクルが同一になるようなまとまり)で Context を分けるよう配慮すべきなのです。
また、分けることでビジネスロジック層の単体テストを書く際にも Context を Mock するのが簡単になる点も大きいです。
export const LogContext = createContext<{
log: (message: string) => void
}>({
log: () => {},
})
ここまでのまとめ
Context の基本的な使い方と、それらをライブラリ層・ビジネスロジック層で分けて使うポイントを見てきました。
ここからはテストする方法について見ていきましょう。
テストパターン1) Context をコンポーネントで使う場合
Context を使っている部分は useContext
を使ってるわけですが、普通の Hook のようには Mock しづらいので特別な方法を取ります。
render(
<MyContext.Provider value={mockContextValue}>
<MyComponent />
</MyContext.Provider>
)
でテスト用の Context 値を注入します。
具体的には下記のようになります。
// テスト対象のコンポーネント
export const MyComponent = () => {
const { value, doSomething } = useContext(MyContext)
return (
<div>
<p>{value}</p>
<button onClick={doSomething}>Click me</button>
</div>
)
}
// テストコード
describe('MyComponent', () => {
it('Context の内容で表示・実行されること', () => {
// モックされた Context の値を定義
const mockContextValue: ContextType<typeof MyContext> = {
value: 'Mocked Value',
doSomething: vi.fn(),
}
// コンポーネントをモック Context でラップしてレンダリング
render(
<MyContext.Provider value={mockContextValue}>
<MyComponent />
</MyContext.Provider>
)
// モック値がレンダリングされているか確認
expect(screen.getByText('Mocked Value')).toBeInTheDocument()
// ボタンをクリックしてモック関数が呼び出されたか確認
fireEvent.click(screen.getByText('Click me'))
expect(mockDoSomething).toHaveBeenCalledTimes(1)
})
})
テストパターン2) Context をカスタム Hook (use から始まる関数) で使う場合
カスタム Hook のテストでは render で注入するのは冗長ですね。もっと最適な方法があります。
renderHook
に wrapper
を渡します。
具体的には下記のようになります。
// テスト対象のコンポーネント
export const useXxx = () => {
const { value, doSomething } = useContext(MyContext)
return {
xxx: value + 1,
yyy: doSomething(),
}
}
// テストコード
describe('useXxx', () => {
it('Context の内容が使われること', () => {
// モックされた Context の値を定義
const mockContextValue: ContextType<typeof useXxx> = {
value: 11,
doSomething: vi.fn().mockReturnValue(22),
}
// コンポーネントをモック Context でラップしてレンダリング
const wrapper = (props: { children: ReactNode }) => (
<MyContext.Provider value={mockContextValue}>
{props.children}
</MyContext.Provider>
)
const { result } = renderHook(() => useXxx(), { wrapper })
// モック値の確認
expect(result.current).toEqual({ xxx: 11, yyy: 22 })
expect(mockContextValue.doSomething).toHaveBeenCalledTimes(1)
})
})
まとめ
Context の使い方とテストの仕方を見てきました。
何か質問・ご意見などあれば気軽に聞いてくださいね。
それでは。