6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactAdvent Calendar 2024

Day 1

React の Context についてちゃんと説明してみる。

Last updated at Posted at 2024-11-30

概要

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>
  )
}

↓ 下記のようにスッキリかけます。

Context を使ってスッキリした例
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 が供給するいずれの値 bbbccc が更新されたときでも 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 で注入するのは冗長ですね。もっと最適な方法があります。
renderHookwrapper を渡します。
具体的には下記のようになります。

// テスト対象のコンポーネント
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 の使い方とテストの仕方を見てきました。
何か質問・ご意見などあれば気軽に聞いてくださいね。

それでは。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?