TypeScript/Reactのコンテキスト初期値と専用の型
TypeScriptからReactを使う場合を前提とします。
Reactにおけるコンテキスト
Reactには コンテキスト(Context) という仕組みがあり、コンポーネントの階層を飛び越えて深い階層にあるコンポーネントとも値をやり取りできます。コンテキストを使うと、コンポーネント階層のあちこちにprops経由で値を引き回す煩雑さを避けられます。
コンテキストの解説としては、公式ドキュメントがとてもよくまとまっています。
コンテキストの使い方は、難しいことを考えないならば単純です。
-
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をすることは通常ありえないのでは?」という問題があります。
- 今回のような
Theme
というお題であれば、デフォルトテーマにフォールバックするのは自然かもしれない - 実質的にuseContext側で特定のプロバイダの存在を期待しているため、「デフォルト値がない」場合がある
後者の場合、すなわち対応するプロバイダがないuseContextをエラーにする実装も、しばしば行われるようです。例えば以下のような記事があります。
また、公式ドキュメントでは初期値に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
を見ると、theme
はTheme | typeof Uninitialized
型になっています。したがって、値Uninitialized
と一致判定をすることで、Greeting
がプロバイダの階層下にあるかどうかをチェックできます。
ただし、これが万能の解決策かというと、議論の余地がありそうです。
- nullやundefinedはプログラム中の様々な場所に現れるので、そうではない専用の型を作る方が、意味が取りやすく可読性に優れるかもしれない。型はドキュメントとしても働くかもしれない
- (おそらく稀なケースではあるが)プロバイダからnullを渡したい時には、デフォルト値nullによるチェックは採用できない
- useContextの戻り値を
null
チェックする実装は一種のイディオムなので、簡潔であり必要十分な実装になっている。あえて複雑な型を導入するメリットに乏しい
もしかすると、比較的大きく複雑なプロジェクトでは、型システムでこういった工夫を凝らすといいのかもしれません。
-
null
ではなくundefined
を使う実装も世の中にはあるようです ↩︎
Discussion