🌲

TypeScript/Reactのコンテキスト初期値と専用の型

2024/07/08に公開

TypeScriptからReactを使う場合を前提とします。

Reactにおけるコンテキスト

Reactには コンテキスト(Context) という仕組みがあり、コンポーネントの階層を飛び越えて深い階層にあるコンポーネントとも値をやり取りできます。コンテキストを使うと、コンポーネント階層のあちこちにprops経由で値を引き回す煩雑さを避けられます。

コンテキストの解説としては、公式ドキュメントがとてもよくまとまっています。

https://ja.react.dev/learn/passing-data-deeply-with-context

コンテキストの使い方は、難しいことを考えないならば単純です。

  • createContextでコンテキストを表すオブジェクトを作る。このときデフォルト値を指定する
    • TypeScriptレベルでは値の一種だが、プロバイダとしての値はまだ設定されていないので、実質的には型という感じ
  • コンテキストを表すオブジェクトにはProviderという属性が生えているので、これにvalue引数を渡す形で、コンテキストの有効範囲と値のペアを指定する
  • プロバイダの子コンポーネントでuseContextするとプロバイダのvalue値が、コンテキスト外でuseContextするとデフォルト値が得られる

エッセンス部分だけ抜き出すと、こんな感じになるでしょうか。

import { useContext, createContext } from "react";

type Theme = "light" | "dark";
const ThemeContext = createContext<Theme>("light");

const Greeting = () => {
  const theme = useContext(ThemeContext);
  return <p>Hello {theme}</p>;
};

const Page = () => {
  return (
    <>
      <Greeting />                         {/* <p>Hello light</p> */}
      <ThemeContext.Provider value="dark">
        <Greeting />                       {/* <p>Hello dark</p> */}
      </ThemeContext.Provider>
    </>
  );
};

export default Page;

より実践的なコードでは、useContextをそのまま使うのではなくカスタムフックを作ったり、プロバイダをカスタムコンポーネントでラップしたりするかもしれません。

コンテキストのデフォルト値を参照するのはおかしい場合もある

さて、createContextのデフォルト値ですが、そもそもとして「参照先のプロバイダがないのにuseContextをすることは通常ありえないのでは?」という問題があります。

  1. 今回のようなThemeというお題であれば、デフォルトテーマにフォールバックするのは自然かもしれない
  2. 実質的にuseContext側で特定のプロバイダの存在を期待しているため、「デフォルト値がない」場合がある

後者の場合、すなわち対応するプロバイダがないuseContextをエラーにする実装も、しばしば行われるようです。例えば以下のような記事があります。

https://dev.to/origamium/context-provider-3b90

また、公式ドキュメントでは初期値にnullを入れるようにしているサンプルが散見されます[1]

type Theme = "light" | "dark";
// 初期値nullを許容するように型を修正
const ThemeContext = createContext<Theme | null>(null);

const Greeting = () => {
  const theme = useContext(ThemeContext);
  // themeがnullであるならば初期値(つまり対応するプロバイダがない)
  if (theme === null) {
    // この実装ではErrorを投げているが、もちろんそれ以外の実装方針もありえるはず
    throw new Error(
      "useContext(ThemeContext) must be used within a ThemeContext.Provider"
    );
  }
  return <p>Hello {theme}</p>;
};

ただ、筆者としては、せっかくTypeScriptを使っているのだから、null, undefinedを用いた処理はできることなら避けたいなと考えました。

TypeScriptならば専用の型を作る選択肢もある

ReactコードをTypeScriptで書く場合、汎用のnullやundefinedを使うのではなく「初期値」を表す型・値を定義することもできます。ここではSymbol型を使います。TypeScriptのSymbolは、ユニークな型と、その型に対する唯一の値を生成する仕組みです。

type Theme = "light" | "dark";
const Uninitialized = Symbol("Uninitialized");
const ThemeContext = createContext<Theme | typeof Uninitialized>(Uninitialized);

const Greeting = () => {
  const theme = useContext(ThemeContext);
  // themeがUninitializedContextを取っているならば対応するプロバイダがない
  if (theme === Uninitialized) {
    throw new Error(
      "useContext(ThemeContext) must be used within a ThemeContext.Provider"
    );
  }
  return <p>Hello {theme}</p>;
};

コンテキストを使う側であるGreetingを見ると、themeTheme | typeof Uninitialized型になっています。したがって、値Uninitializedと一致判定をすることで、Greetingがプロバイダの階層下にあるかどうかをチェックできます。

ただし、これが万能の解決策かというと、議論の余地がありそうです。

  • nullやundefinedはプログラム中の様々な場所に現れるので、そうではない専用の型を作る方が、意味が取りやすく可読性に優れるかもしれない。型はドキュメントとしても働くかもしれない
  • (おそらく稀なケースではあるが)プロバイダからnullを渡したい時には、デフォルト値nullによるチェックは採用できない
  • useContextの戻り値をnullチェックする実装は一種のイディオムなので、簡潔であり必要十分な実装になっている。あえて複雑な型を導入するメリットに乏しい

もしかすると、比較的大きく複雑なプロジェクトでは、型システムでこういった工夫を凝らすといいのかもしれません。

脚注
  1. null ではなく undefined を使う実装も世の中にはあるようです ↩︎

Discussion