stin's Blog

React Context を export するのはアンチパターンではないかと考える


Context を export するな

みなさんは React Context を使っていますか?非常に便利ですよね。 え、使ってない?みんな React Context 使っとる。使ってへんのお前だけ。

冗談はさておき、この記事では Context を export するなという内容をお話しします。

React Context とは

その前に React Context についてざっと解説していきます。

Context は、コンポーネントをまたいだ値の共有を実現するためのオブジェクトです。 createContext で生成することができます。

import { createContext } from "react";

const context = createContext<string>("initial value");

使い方は、まず context.Providervalue に共有したい値を渡します。 Provider というだけあって値を配信する役割を持ちます。

const StringProvider: React.FC = ({ children }) => {
  return <context.Provider value="another value">{children}</context.Provider>;
};

値を配信する側がいれば、値を購読する側もいるということですね。購読する側は useContext というフック関数を使用します。 ( context.Consumer という方法もありますが、日本書紀に載っている古い書き方なので割愛。 )

import { useContext } from "react";

const StringConsumer: React.VFC = () => {
  const stringValue = useContext(context);

  return <span>{stringValue}</span>;
};

配信コンポーネントと購読コンポーネントの位置関係が大切で、購読コンポーネントは配信コンポーネントの配下に存在する必要があります。

export default function App() {
  return (
    <StringProvider>
      <StringConsumer /> {/* another value と表示される */}
    </StringProvider>
  );
}

購読コンポーネントが配信コンポーネントの配下にいればいいので、間に無関係のコンポーネントがいくつ挟まっていても値を取得することができます。

購読コンポーネントの上位層に配信コンポーネントが複数存在する場合は、最も近い配信コンポーネントが配信する値を取得します。

配信コンポーネントを配置していない場合、 createContext に渡された初期値 (ここでは "initial value") を取得します。

Providervalue に渡す値を useState 等で生成すれば、購読コンポーネントの値を配信コンポーネントが制御することができます。

よくある使用例

React でグローバルステートを宣言する最もシンプルな方法として使用されます。

ログイン中のユーザーの情報をアプリ全体で共有したり、選択されたテーマカラーを共有してアプリ全体の配色を一元管理したり、などです。

例えばログインユーザー情報共有の実装は以下のようになります。

type User = { id: string; name: string };

const userContext = createContext<User | null>(null);

const UserProvider: React.FC = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, []);

  return <userContext.Provider value={user}>{children}</userContext.Provider>;
};

const UserInfo: React.VFC = () => {
  const user = useContext(userContext);

  if (user === null) return <div>Loading...</div>;

  return (
    <div>
      <div>id: {user.id}</div>
      <div>name: {user.name}</div>
    </div>
  );
};

export default function App() {
  return (
    <UserProvider>
      <UserInfo />
    </UserProvider>
  );
}

他のモジュールからも参照したくなる

ユーザー情報は当然アプリ全体で同じ値を参照します。Web アプリがひとつのソースファイルで完結するはずもないので、何かしらを export することになりますね。

真っ先に思いつくのが Context オブジェクトを export することです:

export const userContext = createContext<User | null>(null);

このオブジェクトを他のモジュールに import することで、ユーザー情報を参照することができるようになります:

import { userContext } from "./user";

const Greeting: React.VFC = () => {
  const user = useContext(userContext);
  return <p>こんにちは、{user.name}さん</p>;
};

しかしこれは悪手と言えるでしょう。

手段と目的を履き違えている

Context はコンポーネントをまたいだ値の共有を実現するための手段です。一方、購読側コンポーネントが欲しいのは値そのものです。「ドリルを買いに来た客が求めているのはドリルではなく穴である」ということですね。

購読側コンポーネントが userContextuseContextimport して、両者を組み合わせて初めてユーザー情報が参照できるのはあまりに不親切ではないでしょうか。

さらにもうひとつ、直前に購読側の例で挙げた Greeting コンポーネントは実は正常に機能しません。なぜなら userContext で共有される値は null になり得るからです。 購読側コンポーネントは「なぜ null になるか」を考えないといけません。未ログインなのか、取得中なのか、エラーでコケているのか。購読する側がこういった可能性を考えることを強いられてしまうと、そのコンポーネント自身の責務に集中することができませんし、そこらじゅうにハンドリングを書く必要がありコードが無駄に肥大化します。

カスタムフックに隠蔽しよう

購読側コンポーネントからすると、下のようなインターフェイスをしたカスタムフックがあると非常に扱いやすいはずです。

function useLoginUser(): User;

呼び出せばログイン中のユーザーオブジェクトが nullundefined になることなく取得できるとわかるインターフェイスですね。

userContextuseContext は手段でしかないので、これらを useLoginUser の中に隠蔽してしまいましょう。しかしユーザー情報取得中は null になることは間違いありません。どうやってその可能性を取り除けばいいでしょうか。

Provider でハンドリングすればいいのです。

const UserProvider: React.FC = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser().then((user) => setUser(user));
  }, []);

  if (user === null) return <div>Loading...</div>; // <- 取得中は children をマウントさせない

  return <userContext.Provider value={user}>{children}</userContext.Provider>;
};

このようにすれば、 userContext.Providernull が渡ることはありません。 UserProvider 配下で useContext(userContext) を実行すると必ず値が取得できます。

逆に言えば、useContext(userContext)null が取得されてしまうのは、 UserProvider で括られていない位置で実行しようとしているからです。

よって UserProvider の外で useContext(userContext) を実行されるのは例外とみなすことができるでしょう。

これらを踏まえると useLoginUser は以下のような実装になります。

export function useLoginUser(): User {
  const user = useContext(userContext);

  if (user === null) throw new Error("UserProvider でラップしてください。");

  return user;
}

user === null の場合は例外とみなすわけなので、エラーをスローしてしまいます。こうすることで、関数インターフェイスとしては NonNullable なユーザーオブジェクトの取得が可能になるわけです。

そして userContext の代わりに useLoginUserexport します。購読側からは、 userContext という配信側の意図がわからない物体の存在が見えないため、迷うことなく useLoginUser フックを import できるでしょう。また、関数インターフェイス的にも null にならないため、取得した値を自身の責務を果たすために素直に使用することができます。

ただ値を渡すだけだとしてもカスタムフックにすべき

ユーザー情報のように null の可能性を排除するなどのロジックが必要ないとしてもカスタムフックにすべきです。

const context = createContext<"light" | "dark">("light");

export function useTheme() {
  return useContext(context);
}

記述が増えるだけに感じるかもしれませんが、 contextuseContext といった無機質な名前ではなく、 useTheme という意味の込められた関数名をつけることが大切です。

まとめ

React Context を export するなという話をしてきました。

代わりに Context と useContext を内部で使用したカスタムフックを作成してそれを export しましょう。 Context value を使用する際に最低限必要なロジックもそのカスタムフックに詰め込むことで購読側コンポーネントに責務を逸脱したロジックを実装させることを避けることができます。

この記事がみなさんの React 設計の足掛かりになれば幸いです。