React + useContext + TypescriptでDI

TL;DR

Reactなどで、DI的なことをしたくなることがあります。 具体的には、SDKとかをMockしたりするときや、単純にDDD的な感じで実装を勧めている場合です。

大抵の場合、こういったClientRepositoryServiceのようなものは、Reactの再描画を発生させるような状態を持たないことが多いと思います。なので、useContextで全体で共有してしまっても大きな問題は起こりづらいです。

こういった思想のもと、useContextを使ってDIする、的な発想は比較的よく見かけます。

初期化で困る

適当なInterfaceとその実装を定義します。

interface SomeInterface {
  test: () => string;
}

class SomeEntity {
  constructor() {}

  test() {
    return "test";
  }
}

これを単純に初期化すると以下のようになります。しかし、これはuseContextをDIコンテナとして扱うには嬉しくないです。外からinterfaceを定義したものを入れたいのに、最初から特定の実体を持つものが注入されていますし、この時点で何らかの実体に依存しています。

import { createContext } from "react";

export const SomeContext = createContext<SomeInterface>(new SomeEntity());

実用上で嫌な部分としては、実体に依存しているという点と、以下のようにProviderに初期値を与える必要がない部分です。

import { useContext } from "react"

import { SomeContext } from "/path/to/context"

const Test = ({}) => {
    const entity = useContext(SomeContext)

    return <>{entity.test()}</>
}

const App = ({}) => {
    return <SomeContext.Provier><Test /></SomeContext.Provider>
}

export default App;

undefinedで初期化

上で述べたように、初期値として何らかの実体を持ちたくないので、undefinedで初期化します。

import { createContext } from "react";

export const SomeContext = createContext<SomeInterface | undefined>(undefined);

これで実体への依存はなくなりましたが、初期化をSkipしてもエラーにならないという問題と、useContextの帰り値がSomeInterface | undefinedになって、毎回undefinedのチェックが必要になる、という点が嬉しくありません。

これを解決するため、以下の記事を参考にしました。

function createCtx<T>() {
  const ctx = React.createContext<T | undefined>(undefined);
  function useCtx() {
    const c = React.useContext(ctx);
    if (!c) throw new Error("useCtx must be inside a Provider with a value");
    return c;
  }
  return [useCtx, ctx.Provider] as const;
}

この関数を介することで、useCtxの返り値はTになり、Providerを初期化しない場合にはエラーにすることができます。使い方としては以下のような感じです。

const [useSomeContext, SomeContextProvider] = createCtx<SomeInterface>()

const Test = ({}) => {
    const entity = useSomeContext()

    return <>{entity.test()}</>
}

const App = ({}) => {
    return <SomeContextProvier value={new SomeEntity()}><Test /></SomeContextProvider>
}

export default App;

まとめ

Contextundefinedで初期化して、DIしないとエラーにする、かつ返り値にundefinedを含まないカスタムフックを使うことで、型安全にDIっぽいことができるようになります。

この記事に関するIssueをGithubで作成する

Read Next