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 となるため、結果として整合性が取れます。

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

Apollo server で UUID を取得するサーバーを建てる

Apollo server は別にデータソースをデータベース以外にしても問題ないので、GraphQL API で uuid を取得できるサーバーを建てられる。

const { ApolloServer, gql } = require('apollo-server');
const { v4: uuidv4 } = require('uuid');

const typeDefs = gql`
  type Query {
    uuidv4: String
  }
`;
const resolvers = {
  Query: {
    uuidv4: () => uuidv4(),
  },
};
const server = new ApolloServer({ typeDefs, resolvers });

server.listen().then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`);
});

これだけ。あとはクエリを叩けばUUIDが手に入る。

query GetUUID{
  uuidv4
}

Apollo server を触り始めたばかりだけどこの時点で無限に遊べそうでわくわくしてる

T extends unknown が no-unnecessary-type-constraint でできない

TSX のなかでジェネリクスを書くとき、<T> の代わりに <T extends unknown> と書くテクニックは有名ですよね

const f = <T>(n: T) => n; // <T> が JSX として認識されてコンパイルできない

const g = <T extends unknown>(n: T) => n; // これは OK

しかし、 @typescript-eslint/no-unnecessary-type-constraint というリントルールを有効化していると、この書き方はエラーになります。まあ、extends unknown は意味ないですから当然ですよね…

ではどうすれば良いかと言うと、<T, >と書くと良いです。

const h = <T,>(n: T) => n; // <T,> は JSX として認識されないので OK

(この記事は sunnyone さんのツイートを参考にしています)

簡潔な文章を書くコツ

リモートワークの普及でテキストベースのコミュニケーションが増えてきました。簡潔で伝わりやすい文章を書いて同僚と差をつけましょう!

なんで簡潔な文章を書くのか?

人間は文章を"読めない"から。 読まれる前提で書いてはいけない。テキストコミュニーケーションをする時は特に気をつけてください。

簡潔な文章の書き方

  • 結論から書く
  • 文章を短くする
  • 単語をできるだけ削る
  • 指示語をなくす
  • 理由を書くな
  • 意味を一意に定める
  • そもそも文法をちゃんとする

結論から書く

まず結論を書きましょう。伝えたいことをズドンと書く。御託はそのあと並べてください。

例えば次の2文はどちらが読みやすいですか?

  • 「あとどのくらいかかりそう?」「こういう仕様にかなり実装コストがかかりそうなので遅れそうです」
  • 「あとどのくらいかかりそう?」「遅れそうです。こういう仕様にかなり実装コストがかかりそうです。」

どちらが良いか、一目瞭然ですよね。後者です。まず質問に答えましょう。御託を並べるな。

文章を短くする

1文あたり20文字くらいを目安に文章を分割しましょう。30文字以上ある文章は大抵どこかで区切れます(区切れないケースもあります)。

コツとしては、接続詞で区切るとやりやすいです。

  • 「〜ですが、」→「〜です。しかし、」
  • 「〜であるため、」→「〜です。そのため、」

短文を心がけると機械翻訳で正確に訳されやすいメリットもあります。とにかく短くしましょう。

単語をできるだけ削る

単語を削れば文章が短くなります。短い文章は読まれる確率が上がります。 意味も分かり易いです。

削れる単語の一例を挙げます。

  • かなり
  • という
  • こと
  • (動詞)をすることができます → (動詞)られます

特に「かなり」や「すごく」は気を抜くと使いがちです。しつこく消していきましょう。

文章を短くするには、同じ意味でもっと短く書けないか常に考える癖をつけると良いです。

指示語をなくす

指示語(こそあど)があると、読み手に「ここにある『これ』は何を指しているんだ?」と思わせます。この記事を読んでる時点であなたは文章の書き方が分かっていません。指示語を使えるなんて驕らないでください。

書き手が指示語を置き換えるのは簡単な作業です。「これ」「それ」は文章から絶対に消しましょう。

理由を書くな

ほとんどのケースで相手は理由を知りたくありません。切りましょう。「理由」は相手が訪ねてきた時だけ書きましょう。

一意に定まるかを意識する

前後の文脈から単語の意味が明らかでないなら、一意に定まるよう修正しましょう。

一意に定まりづらい例を挙げます

  • 一般的な単語
  • 略語
  • 目的語を略す

逆に、一意に定まるものを挙げます

  • これまで何度も登場している

一意に定まるようにするには、修飾語をつける、省略をやめると良いです。

そもそも文法はちゃんとしよう

主語と述語がおかしくなっていないか?助詞の使い方は問題ないか?主語を省略した場合、相手は主語を理解できるか?このあたりはしっかり抑えましょう。まず文法がしっちゃかめっちゃかだと、その時点で読みづらいです。

Extra: 相手が知らない前提で書く

文章は基本的に相手が知らない前提で書きましょう。

例: サーバー起動しておいてください

サーバーの起動方法を相手が知っていれば問題ありません。しかし、知らない可能性がある場合は手順を示すとなお良いです。

改善案: サーバー起動しておいてください。手順はこちらのwikiページに書いてあります(リンク)

もちろん相手が絶対に知っているとわかっていれば不要です。しかし、基本的には知らない前提が良いでしょう(特にチームを跨ぐ場合)。

遅延読み込みリストをカンタンに作りたい

React で遅延読み込み機構を作ってみました。

デモ

github.com

コード

useShownuseUpdateHeight という2つのフックを使って実現します。useShown はスクロール状況から「読み込みを開始すべきか」を判定します。useUpdateHeight はコンポーネントの高さをストアへ伝えます。

import {
  FC,
  useEffect,
  useRef,
  useState,
  createContext,
  Children,
  useCallback,
} from 'react';

const HeightContext = createContext<
  [number[], (index: number, height: number) => void]
>([[], () => null]);
const IndexContext = createContext(0);

/**
 * height store をつなぐ処理と、index を各要素に与えるためのラッパー
 */
const LazyLoadWrapper: FC = ({ children }) => {
  const [height, setHeight] = useState<number[]>([]);
  const updateHeight = useCallback((index: number, height: number) => {
    setHeight((arr) => {
      const newValue = arr.concat();
      newValue[index] = height;
      return newValue;
    });
  }, []);
  return (
    <HeightContext.Provider value={[height, updateHeight]}>
      {Children.map(children, (child, index) => (
        <IndexContext.Provider value={index} key={index}>
          {child}
        </IndexContext.Provider>
      ))}
    </HeightContext.Provider>
  );
};

/**
 * 遅延読み込みされるコンポーネント
 */
const Child = () => {
  const divRef = useRef<HTMLDivElement>(null);
  const shown = useShown();
  const updateHeight = useUpdateHeight();
  const height = useRef((Math.floor(Math.random() * 9) + 1) * 100).current;

  useEffect(() => {
    if (divRef.current && shown) {
      updateHeight();
    }
  }, [shown, updateHeight]);

  if (!shown) {
    return null;
  }
  return <div style={{ width: 100, height }} ref={divRef}></div>;
};

export const App= () => {
  return (
    <LazyLoadWrapper>
      <Child />
      <Child />
      <Child />
      <Child />
      <Child />
    </LazyLoadWrapper>
  );
};

各コンポーネントで高さを伝達し、LazyLoadWrapper で囲むだけで遅延読み込みが実現できます。

useShown と useUpdateHeight の実装

結構シンプルです。useShown は「まだマウントしてない要素のうち一番上にあって、かつスクロール量が閾値を超えているか」を判定しているだけです。閾値は適当に「画面の高さ * 3」だけの余裕が取れるようにしています。

useUpdateHeight はさらにシンプルで、HeightContext の配列を更新するだけです。

import { useEffect, useState, useCallback, useContext } from 'react';

/**
 * 「表示するべきか否か」を返すフック。一度 shown が true になったら false になることはない。
 * height store の更新はしない。
 */
const useLazyload = () => {
  const index = useContext(IndexContext);
  const [height] = useContext(HeightContext);
  const [shown, setShown] = useState(false);

  useEffect(() => {
    if (shown) {
      return;
    }
    const nextIndexToMount = (() => {
      const indexOfFirstUndefined = height.findIndex(
        (item) => typeof item === 'undefined'
      );
      if (indexOfFirstUndefined !== -1) {
        return indexOfFirstUndefined;
      }
      return height.length;
    })();

    // 先頭から連続したマウント済み要素の高さの総和
    const totalHeight = height
      .slice(0, nextIndexToMount)
      .reduce((a, b) => a + b, 0);
    const listener = () => {
      if (
        nextIndexToMount === index &&
        totalHeight < 3 * window.innerHeight + window.scrollY
      ) {
        setShown(true);
      }
    };
    window.addEventListener('scroll', listener);
    return () => {
      window.removeEventListener('scroll', listener);
    };
  }, [height, index, shown]);
  return shown;
};

/**
 * コンポーネントの高さをストアへ伝えるためのフック。
 */
const useUpdateHeight = () => {
  const index = useContext(IndexContext);
  const [, setHeight] = useContext(HeightContext);
  return useCallback(
    (height: number) => setHeight(index, height),
    [index, setHeight]
  );
};