Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

型安全に React の Provider をマージしてネストを浅くしたい

苦節半年くらいしてようやく実現できてメッチャうれしいので記事も書くぞ!!!

Context のネストがつらい

React の Context、いくつも書くとなるとネストがどんどん深くなっていってつらいですよね。

<ContextA.Provider>
  <ContextB.Provider>
    <ContextC.Provider>
      <ContextD.Provider>
        <ContextE.Provider>
          ...
        </ContextE.Provider>
      </ContextD.Provider>
    </ContextC.Provider>
  </ContextB.Provider>
</ContextA.Provider>

これ、フラットにしたくありませんか?

<MergedProvider>
  ...
</MergedProvider>

今回はこれが実現できたので紹介します!!!!

コード

import { Context, createContext, PropsWithChildren } from 'react';

type CheckContexts<T> =
  T extends (infer R)[] ?
    R extends [Context<infer _>, infer _] ?
      true
    : false
  : false;
const MergeProvider = <T extends (readonly [] | readonly any[])[]>({
  contexts,
  children,
}: PropsWithChildren<{ contexts: T } & (false extends CheckContexts<T> ? void : {})>) =>
  contexts.reduceRight(
    (acc, [Context, value]) => <Context.Provider value={value}>{acc}</Context.Provider>,
    children,
  );

const NumContext = createContext(0);
const StringContext = createContext('string');

const App = () => (
  <MergeProvider
    contexts={[
      [NumContext, 0],
      [StringContext, 'string'],
    ]}
  >
    <div />
  </MergeProvider>
);

playground で動かす

invalid な contexts が入ってきた場合、MegeProvider の props の型は void になり、何も受け付けなくなります。これにより型検査が通りません。やったね。

実際、以下のようにコンテキストと値の型がズレていると型検査が通りません。

const App = () => (
  <MergeProvider
    contexts={[
      [NumContext, '0'], // 型が合ってないので型検査が通らない
      [StringContext, 'string'],
    ]}
  >
    <div />
  </MergeProvider>
);

検証用の playground コードも置いておきます。 TypeScript playground

(上のコードは 2024/01/06 に追記したものです。以前のコードは以下に折りたたんでおいておきます)

以前のコード

コード

(こちらは改善前のコードになります)

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

import { FC, Context, ReactNode, createContext } from 'react';
type IsProvider<T> = T extends (infer R)[] ? R extends [Context<infer x>, infer y] ? x extends y ? JSX.Element : void : void : void;

const mergeProvider = <T extends (readonly [] | readonly any[])[]>(contexts: T, children: ReactNode): IsProvider<T> => {
    const res = contexts.reduce((acc, [Context, value]) => {
        return <Context.Provider value={value}>{acc}</Context.Provider>;
    }, children);
    return res as IsProvider<T>;
}

const NumContext = createContext(0);
const StringContext = createContext('string');

const App: FC = () => {
  return mergeProvider([
    [NumContext, 0],
    [StringContext, 'string']
  ], <div />);
}

こんな感じです。

invalid な contexts が入ってきた場合、megeProvider は void を返してくれるため、型検査が通りません。やったね。

const App: FC = () => {
  return mergeProvider([
    [NumContext, '0'], // invalid なので型検査が通らない
    [StringContext, 'string']
  ], <div />);
}

仕組み

仕組みとしてはごく単純で、contexts はなんでも受け入れておき、処理も context と value のタプルが渡されてきたと想定して書きました。当然このままだと実行時エラーがおきます(なんでも受け入れてるので)。しかし、正常な組み合わせ以外が渡された場合、型の返り値が void となるため、結果として整合性が取れます。

全く正しくない引数を受け入れるという発想ができるまですごく時間がかかりました…