統率の取れた実装をするためのReactとTypeScriptのTips

目次

こんにちは。株式会社ハイヤールーのHimenonです。アドベントカレンダー22 日目の記事です!

今回は統率の取れた実装を行う上で React と TypeScript の実装の Tips を紹介していきたいと思います。また、ここで紹介する実装は CodeSandbox に動く状態で公開されています。読みながら実際に動かして確認してみてください。

本記事で紹介する話のスコープ

本記事で扱うスコープ

  • 実装レベルの話

取り扱わないスコープ

  • ファイル名やディレクトリ名、それらの依存関係

です。

なぜ実装レベルで統率が必要なのか

もっとも簡単な理由は実装・レビューが最速になり、保守効率が上がるためです。

実装がパターン化され、さらに名称がつくと実装する前に設計段階でどのパターンで実装することが可能か、瞬時に判断することができます。裏を返せば、チーム開発において独自実装属人性コードの保守性の非効率さを生み出し、負債となる懸念が大きくなります。

とはいえ、それが必要なケースも存在するため、すべてを一律に厳格に制御することは不可能です。これに対して、我々はアーキテクチャレベルで最小限のスコープでルール違反を許容するディレクトリやパッケージを切り出す方法を用意しています。この話は本記事では扱いませんが、別記事「pnpm と dependency-cruiser で Web フロントエンドの Monorepo を支える」でアーキテクチャレベルの話を一部紹介しています。

それでは、より堅牢なアプリケーションを構築するための実装パターンを紹介していきます。

React の実装パターン

ここでは以下の手法について、それぞれ紹介していきます。

props の抽象度をコントロールする方法

  • 階層化されたコンポーネントの Props の Interface を構築する場合、下層のコンポーネントの Props をそのまま利用する
  • 下層のコンポーネントの Props を部分的に上層に露出する利用する場合は Omit, Pick を利用する

DRY を実現する方法

  • props で渡した callback から props を返さない
  • 配列で表現されるコンポーネントを Object で表現する
  • 入れ物のコンポーネントと中身のコンポーネントを分離する

Presentation 層のコンポーネントは props の全単射にする方法

  • React の Component 内で find/filter/reduce は利用しない

props の抽象度をコントロールする

例えばユーザーのプロフィールを編集するコンポーネント UserProfileEditForm を考えてみます。

まずは非推奨のパターンです。

非推奨パターン

UserProfileEditForm.tsx

import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import * as React from "react";

export type UserProfileEditFormProps = {
  userName: string;
  mailAddress: string;
  language: string;
  photoUrl: string;
};

const UserProfileEditForm: React.FC<UserProfileEditFormProps> = (props) => {
  return (
    <Box>
      <Grid container>
        <Grid item xs={6}>
          <TextField
            name="username"
            label="ユーザー名"
            value={props.userName}
          />
        </Grid>
        <Grid item xs={6}>
          <TextField
            name="mailAddress"
            label="メールアドレス"
            value={props.userName}
          />
        </Grid>
        <Grid item xs={6}>
          <TextField name="language" label="使用言語" value={props.language} />
        </Grid>
        <Grid item xs={6}>
          <TextField name="photoUrl" label="AvatarUrl" value={props.photoUrl} />
        </Grid>
      </Grid>
    </Box>
  );
};

UserProfileEditForm.displayName = "UserProfileEditForm";

export default UserProfileEditForm;

なぜ非推奨なのか

  • JSX(上記の TextField)に対して直接 props を渡しているため、props もしくは JSX のいずれかの変更によって他方の変更もあるようにレビュー時に見える
  • ユーザー名で利用している TextField の props を拡張したい場合に改めて props を渡す必要がある
  • UserProfileEditFormProps の props の変更によってコンポーネント内の変更が発生しやすい

これらの問題を改善する方法として次のような書き方を定義しています。このコンポーネントをリファクタリングする方法を抽象度の高い順に紹介していきます。

階層化されたコンポーネントの Props の Interface を構築する場合、下層のコンポーネントの Props をそのまま利用する

UserProfileEditForm.tsx

import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import * as React from "react";

export type UserProfileEditFormProps = {
  userName: TextFieldProps;
  mailAddress: TextFieldProps;
  language: TextFieldProps;
  photoUrl: TextFieldProps;
};

const UserProfileEditForm: React.FC<UserProfileEditFormProps> = (props) => {
  return (
    <Box>
      <Grid container>
        <Grid item xs={6}>
          <TextField {...props.userName} />
        </Grid>
        <Grid item xs={6}>
          <TextField {...props.mailAddress} />
        </Grid>
        <Grid item xs={6}>
          <TextField {...props.language} />
        </Grid>
        <Grid item xs={6}>
          <TextField {...props.photoUrl} />
        </Grid>
      </Grid>
    </Box>
  );
};

UserProfileEditForm.displayName = "UserProfileEditForm";

export default UserProfileEditForm;

この方法は次のことを実現しています

  • UserProfileEditFormProps のプロパティはすべて下層のコンポーネントの props(TextFieldProps)を利用し、UserProfileEditForm を利用する親のコンポーネントに props 定義を移譲している。
  • Spread Operator を利用することで下層のコンポーネントで利用したい props が増えた場合に、UserProfileEditForm 内の実装に対して変更を加える必要がない。

props を外側で定義することで props と JSX の実装距離を分離しています。しかしながら、この方法は利用する側の親コンポーネントに実装の負担が行くため必ずしも扱いやすい抽象度になっているとは言えません。

もう少し抽象度を下げた方法を紹介します。

下層のコンポーネントの Props を部分的に上層に露出する利用する場合は Omit、Pick を利用する

下層のコンポーネントの Props を親から再度すべて露出させるやり方は抽象度が高すぎるため、親コンポーネントが選択できるプロパティが無数に発生します。これでは下層のコンポーネントが意図する props がどれか読み取ることが困難になります。

これを避けるため、下層のコンポーネントが露出する props をPickOmitで制限します。例えば、UserProfileEditFormPropsの各フィールドのnameを固定して、valueonChangeだけ露出させたい場合は次のように書けます。

import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import * as React from "react";

export type UserProfileEditFormProps = {
  userName: Pick<TextFieldProps, "defaultValue" | "onChange">;
  mailAddress: Pick<TextFieldProps, "defaultValue" | "onChange">;
  language: Pick<TextFieldProps, "defaultValue" | "onChange">;
  photoUrl: Pick<TextFieldProps, "defaultValue" | "onChange">;
};

const UserProfileEditForm: React.FC<UserProfileEditFormProps> = (props) => {
  const userNameProps: TextFieldProps = {
    ...props.userName,
    name: "username",
  };
  const mailAddressProps: TextFieldProps = {
    ...props.mailAddress,
    name: "mailAddress",
  };
  const languageProps: TextFieldProps = {
    ...props.language,
    name: "language",
  };
  const photoUrlProps: TextFieldProps = {
    ...props.photoUrl,
    name: "photoUrl",
  };
  return (
    <Box>
      <Grid container>
        <Grid item xs={6}>
          <TextField {...userNameProps} />
        </Grid>
        <Grid item xs={6}>
          <TextField {...mailAddressProps} />
        </Grid>
        <Grid item xs={6}>
          <TextField {...languageProps} />
        </Grid>
        <Grid item xs={6}>
          <TextField {...photoUrlProps} />
        </Grid>
      </Grid>
    </Box>
  );
};

UserProfileEditForm.displayName = "UserProfileEditForm";

export default UserProfileEditForm;

整理すると、この書き方は次のことを実現しています。

  • UserProfileEditFormProps が露出する props を制限することで UserProfileEditForm の抽象度を下げた。
  • UserProfileEditForm 内で固定の props と外側から注入する props を合成する場合、変数で一度受けてから JSX に Spred Operator で渡す。

制御している抽象度について整理すると次のようになります。

**実装箇所****役割**UserProfileEditFormProps 外界との責務境界 UserProfileEditForm ~ return 文外界とコンポーネント内の合成の責務 return 文コンポーネントレイアウトの責務

コンポーネントの実装をこれらの 3 つの場所に分散させることで、実装者の変更意図を読み取ることが容易になり、抽象度をうまくコントロールすることができます。

DRY を実現する方法

必ずしも、コンポーネントを切り出したから再利用性が高まって DRY が実現できるわけではありません。再利用可能なインターフェースを提供するからこそ再利用性が高まります。ここでは発生しやすい DRY(Don’t repeat yourself)な実装を取り上げ、その対策について紹介します。

props で渡した callback から props を返さない

ListArticle と Article のコンポーネントで説明します。ListArticle は Article を複数持つコンポーネントです。

非推奨の書き方

Article.tsx

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import * as React from "react";

export type ArticleProps = {
  articleId: number;
  title: string;
  onClickGood: (articleId: number) => void;
};

const Article: React.FC<ArticleProps> = (props) => {
  return (
    <Box>
      <Typography>{props.title}</Typography>
      <Button onClick={() => props.onClickGood(props.articleId)}>GOOD</Button>
    </Box>
  );
};

Article.displayName = "Article";

export default Article;

ListArticle.tsx

import Box from "@mui/material/Box";
import * as React from "react";

import Article from "./Article";

export type ListArticleProps = {
  articles: { title: string; id: number }[];
  onClickGood: (articleId: number) => void;
};

const ListArticle: React.FC<ListArticleProps> = (props) => {
  return (
    <Box>
      {props.articles.map((article) => {
        return (
          <Article
            key={article.id}
            articleId={article.id}
            title={article.title}
            onClickGood={props.onClickGood}
          />
        );
      })}
    </Box>
  );
};

ListArticle.displayName = "ListArticle";

export default ListArticle;

これらのコンポーネントの問題点は次のところにあります。

  • 前節で紹介した抽象度のコントロールが機能していない
  • Article.tsxonClickGoodの引数はArticle.tsxの props で渡した値を返している。

このコンポーネントの設計ミスは

  • ListArticle が受け取った Callback onClickGoodを Article に対して共通化している点

にあります。この設計は一見するとうまく共通化できているように見えますが、無駄な点があります。まずはリファクタリングした例を紹介します。

ListArticles.tsx

import Box from "@mui/material/Box";
import * as React from "react";

import Article, { ArticleProps } from "./Article";

export type ListArticleProps = {
  articles: ArticleProps[];
};

const ListArticle: React.FC<ListArticleProps> = (props) => {
  return (
    <Box>
      {props.articles.map((articleProps) => {
        return <Article key={articleProps.articleId} {...articleProps} />;
      })}
    </Box>
  );
};

ListArticle.displayName = "ListArticle";

export default ListArticle;

Article.tsx

import Box from "@mui/material/Box";
import Button, { ButtonProps } from "@mui/material/Button";
import Typography, { TypographyProps } from "@mui/material/Typography";
import * as React from "react";

export type ArticleProps = {
  articleId: number;
  title: Pick<TypographyProps, "children">;
  goodButton: Pick<ButtonProps, "onClick">;
};

const Article: React.FC<ArticleProps> = (props) => {
  const goodButtonProps = {
    ...props.goodButton,
    children: "GOOD",
  };
  return (
    <Box>
      <Typography {...props.title} />
      <Button {...goodButtonProps} />
    </Box>
  );
};

Article.displayName = "Article";

export default Article;

非推奨の実装パターンとの違いは次のとおりです。

  • ListArticlePropsからonClickGoodのプロパティが消えた
  • Articleコンポーネントの Good Button のonClickに props 経由の articleId を渡さない

なぜこれが可能なのか、ListArticle に渡す props を見て説明します。ListArticlePropsのダミー値を書き下すと次のようになります。

const listArticleProps: ListArticleProps = {
  articles: [
    {
      articleId: 1,
      title: {
        children: "タイトル2",
      },
      goodButton: {
        onClick: () => {
          // some action
        },
      },
    },
    {
      articleId: 2,
      title: {
        children: "タイトル2",
      },
      goodButton: {
        onClick: () => {
          // some action
        },
      },
    },
  ],
};

しかしながら実際には API などから取得したデータは「タイトル」や「記事 ID」だけであり、goodButtonなどのプロパティはこの時点ではありません。つまり、API から Component の Props に変換する処理が必ず存在します。例えば、以下のようにArticleSourceを API 経由で取得したデータとすると Props に変換する処理は map を通して書くことができます。

type ArticleSource = {
  id: number;
  title: string;
};

const articleSourceList: ArticleSource[] = [
  {
    id: 1,
    title: "タイトル1",
  },
  {
    id: 2,
    title: "タイトル2",
  },
];

const listArticleProps: ListArticleProps = {
  articles: articleSourceList.map((article) => {
    return {
      articleId: article.id,
      title: {
        children: article.title,
      },
      goodButton: {
        onClick: () => {
          console.log(`Article ID = ${article.id}`);
        },
      },
    };
  }),
};

見て分かる通り、articleSourceListmap関数内でgoodButtonに対してonClickが定義できます。この時点で goodButton はarticleIdを知っているため、非推奨パターンのような articleId を必要とするようなコールバックonClickGoodを定義する必要はありません。

配列で表現されるコンポーネントを Object で表現する

同じコンポーネントを並べる場合は配列で表現することが可能ですが、”props の抽象度をコントロールする”で紹介したように、Props が抽象化されすぎると親コンポーネントに責務が移譲されることがあります。

例えば Props を配列で露出させた場合、配列に対してどの順番で何を詰め込むか、は親のコンポーネントの責務になります。

具体的に見ていきます。True/False を表現するための SwitchField を SettingsPanel が複数持っているようなコンポーネントを考えます。SettingPanel 上で SwitchField は 2 カラムで表現するため、Grid を利用して並べようとしています。

SettingPanel.tsx

import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";
import * as React from "react";

import SwitchField, { SwitchFieldProps } from "./SwitchField";

export type SettingPanelProps = {
  switchFields: SwitchFieldProps[];
};

const SettingPanel: React.FC<SettingPanelProps> = (props) => {
  return (
    <Box>
      <Typography>設定パネル</Typography>
      <Grid container spacing={2}>
        {props.switchFields.map((switchFieldProps) => {
          return (
            <Grid item xs={6}>
              <SwitchField {...switchFieldProps} />
            </Grid>
          );
        })}
      </Grid>
    </Box>
  );
};

SettingPanel.displayName = "SettingPanel";

export default SettingPanel;

SwitchField.tsx

import Box from "@mui/material/Box";
import Switch, { SwitchProps } from "@mui/material/Switch";
import Typography, { TypographyProps } from "@mui/material/Typography";
import * as React from "react";

export type SwitchFieldProps = {
  label: Pick<TypographyProps, "children">;
  switch: SwitchProps;
};

const SwitchField: React.FC<SwitchFieldProps> = (props) => {
  return (
    <Box>
      <Typography {...props.label} />
      <Switch {...props.switch} />
    </Box>
  );
};

SwitchField.displayName = "SwitchField";

export default SwitchField;

この SettingPanel コンポーネントが親のコンポーネントに移譲してしまっている責務があります。

  • switchFields の順番は親が決める必要がある。

Unorderd なリストであればこの責務で問題ありませんが、例にあげている題材は「設定パネル」というスコープで区切られており、何が設定できるのかほとんどのケース固定です。したがって、SettingPanel を利用する側の視点に立つと、SettingPanel がどのような設定ができるのか知っておきたいはずです。

これを表現するには次のように表現します。

SettingPanel.tsx

import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";
import * as React from "react";

import SwitchField, { SwitchFieldProps } from "./SwitchField";

export type SettingPanelProps = {
  notifyWhenTestStarted: SwitchFieldProps;
  notifyWhenTestEnded: SwitchFieldProps;
  notifyWhenTestEvaluated: SwitchFieldProps;
  notifyWhenTestReviewed: SwitchFieldProps;
};

const SettingPanel: React.FC<SettingPanelProps> = (props) => {
  const switchFieldsProps: SwitchFieldProps[] = [
    props.notifyWhenTestStarted,
    props.notifyWhenTestEnded,
    props.notifyWhenTestEvaluated,
    props.notifyWhenTestReviewed,
  ];
  return (
    <Box>
      <Typography>設定パネル</Typography>
      <Grid container spacing={2}>
        {switchFieldsProps.map((switchFieldProps) => {
          return (
            <Grid item xs={6}>
              <SwitchField {...switchFieldProps} />
            </Grid>
          );
        })}
      </Grid>
    </Box>
  );
};

SettingPanel.displayName = "SettingPanel";

export default SettingPanel;

変更前との差分は以下のとおりです。

  • SettingPanelProps のswitchFields: SwitchFieldProps[]が名前付きのプロパティに分解された
  • SettingPanel コンポーネント内でswitchFieldsPropsを定義し、配列を props のプロパティから再構築している

これによって実現されることは、

  • SettingPanel を利用するコンポーネントは各設定項目が props のプロパティ名でわかる
  • SettingPanel を利用するコンポーネントは各設定項目の順序を気にする必要がない

です。配列で表現することでコンポーネントが再利用可能な状態になりますが、抽象度をある程度下げなければ利用側から見たときに万能なコンポーネントに見えることがあります。配列をオブジェクトのプロパティに分解し、コンポーネント内で配列にマッピングすることで凝集度の高いコンポーネントが構築できます。

入れ物のコンポーネントと中身のコンポーネントを分離する

UI/UX のために画面に表示する情報量を制限することがあります。例えばダイアログやStepperのように別のコンテキストであることを明示するためにコンポーネントを分けたり、長すぎるコンテキストを分割して表示したり、利用するユーザーから見たら UI/UX 的には嬉しいが実装者泣かせのコンポーネントがあります。

断片化された情報を持つコンポーネントの再利用性を高めるためにできる実装パターンについて紹介します。実装を紹介する前に図で理解の解像度を上げておきましょう。

例えば以下のようなダイアログ(Material UI - Transitions より)があります。

これをコンポーネントに分解する場合、Dialog(入れ物)とその中身で分割します。

このとき、Dialog 側のコンポーネントは次のように書けます。

ConfirmDialog.tsx

import Button, { ButtonProps } from "@mui/material/Button";
import Dialog, { DialogProps } from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import * as React from "react";

export type ConfirmDialogProps = {
  open: boolean;
  onClose: DialogProps["onClose"];
  agreeButton: Pick<ButtonProps, "onClick">;
  disagreeButton: Pick<ButtonProps, "onClick">;
  Content: React.ReactNode;
};

const ConfirmDialog: React.FC<ConfirmDialogProps> = (props) => {
  const dialogProps: DialogProps = {
    open: props.open,
    keepMounted: true,
    onClose: props.onClose,
    "aria-describedby": "alert-dialog-slide-description",
  };
  return (
    <Dialog {...dialogProps}>
      {props.Content}
      <DialogActions>
        <Button {...props.disagreeButton}>Disagree</Button>
        <Button {...props.agreeButton}>Agree</Button>
      </DialogActions>
    </Dialog>
  );
};

ConfirmDialog.displayName = "ConfirmDialog";

export default ConfirmDialog;

props のContentは ReactNode で受け取るため、Dialog は何が表示されるかわかりません。逆に、注入する ReactNode は Dialog 側のことについて知らなくて良いのでコンポーネントのポータビリティを確保できます。

これでうまく機能するように見えますが、もう一歩踏み込んだ実装をする必要があります。例えばこの Dialog のagreeButtonが注入した ReactNode に含まれる Form の Submit ボタンだった場合、どのように実装すべきか、という問題に解決策を提供する必要があります。

React.Context を利用する

この問題に対する現在の解は、中身のコンポーネントが React Context を提供し、コンポーネントの制御を外部から可能にすることです。具体的な実装を示します。

Dialog の中身のコンポーネントを例えば Coutner コンポーネントとします。AgreeButton をクリックしたら+1、DisagreeButton をクリックしたら-1 するだけのコンポーネントを考えます。

このとき、Counter のコンポーネントを外部から制御するための Context を用意します。

Context.tsx

import * as React from "react";

export type ContextValue = {
  count: number;
  countUp: () => void;
  countDown: () => void;
};

export const CounterContext = React.createContext<ContextValue>({
  count: 0,
  countUp: () => undefined,
  countDown: () => undefined,
});

export const useCounterContext = () => React.useContext(CounterContext);

export type CounterProviderProps = {};

export const CounterProvider: React.FC<
  React.PropsWithChildren<CounterProviderProps>
> = (props) => {
  const [count, updateCount] = React.useState(0);
  const contextValue: ContextValue = {
    count: count,
    countUp: () => {
      updateCount((prev) => prev + 1);
    },
    countDown: () => {
      updateCount((prev) => prev - 1);
    },
  };
  return (
    <CounterContext.Provider value={contextValue} children={props.children} />
  );
};

Counter の Component は Context 経由で count の State にアクセスします。

Counter.tsx

import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import * as React from "react";

import { useCounterContext } from "./Context";

export {
  type CounterProviderProps,
  CounterProvider,
  useCounterContext,
} from "./Context";

export type CounterProps = {};

const Counter: React.FC<CounterProps> = () => {
  const { count } = useCounterContext();
  return (
    <Box>
      <Typography>Count: {count}</Typography>
    </Box>
  );
};

Counter.displayName = "Counter";

export default Counter;

Dialog 側で Counter を制御するためには CounterProvider の配下に Counter と Dialog の両方を配置する必要があるため、次のように 2 つ Wrapper、WrapperContent のコンポーネントを用意します。

import ConfirmDialog, { ConfirmDialogProps } from "./ConfirmDialog";
import Counter, { CounterProvider, useCounterContext } from "./Counter";

const WrapperContent = () => {
  const counterContext = useCounterContext();
  const confirmDialogProps: ConfirmDialogProps = {
    open: true,
    onClose: () => {
      console.info("closed");
    },
    agreeButton: {
      onClick: () => {
        counterContext.countUp();
      },
    },
    disagreeButton: {
      onClick: () => {
        counterContext.countDown();
      },
    },
    Content: <Counter />,
  };
  return <ConfirmDialog {...confirmDialogProps} />;
};

const Wrapper = () => {
  return (
    <CounterProvider>
      <WrapperContent />
    </CounterProvider>
  );
};

ここで実現されていることを整理すると次のようになります。

  • CounterProvider 配下の WrapperContent では useCounterContext が利用可能になる
  • useCounterContext によって Counter の制御メソッドが手に入る
  • Counter の制御メソッドを ConfirmDialogProps に提供することで、ConfirmDialog 側のボタンが Counter を制御できる

コンポーネントの再利用を維持しつつ、それぞれのコンポーネントの責務を維持しています。

とはいえ、冗長な書き方が増えるため、情報が断片化されるコンポーネントを利用する際、小さなコンポーネントでは DRY をある程度許容してしまうのも一つの解になりえると考えます。

Presentation 層のコンポーネントの props を全単射にする方法

コンポーネントの品質を高めるためにはテストは必要不可欠です。そして、そのテストが実装をどれだけカバーできているか(コードカバレッジ)、が品質を定量的に見るための一つの指標になります。

React の Component は Props によって大多数の挙動が決定されます。逆に言えば、Props によって挙動が決まらないような副作用のある処理はなるべく避けるのがコードカバレッジを獲得する一つの方法です。

これはつまり、副作用となりうる処理をそもそもしないか、コンポーネントの外側に持ち出し、React のライフサイクルから剥がす事が必要です。

このときに Presentation 側の Props をどのような扱いをすべきか定義しておくことが重要です。数学の用語で全単射という言葉があります。f: A → B という写像に対して全射性と単射性の両方を満たすような写像のことを指します。

図中の X が Props の集合、Y が Component ※ 1 つ点が Props や Component でありプロパティを指しているわけではない

これを React のコンポーネント設計の言葉に翻訳すると、次の 2 つのことを指します。

  • Component に与えられた Props によって Component の振る舞いが一意の状態になる
  • Component に与えられた Props によって Component の振る舞いのすべてのパターンが網羅される

副作用があるコンポーネントの場合、単射だが全射ではない状態になります。これにより Props の制御のみでカバレッジを 100%を目指すことは容易ではありません。

わかりやすい例を挙げます。

Component 内で find/filter/reduce は利用しない

簡単な検索ページを作ってみます。与えられた文字列の配列をユーザーの入力した文字列の部分マッチで絞り込む機能を持ちます。

import Box from "@mui/material/Box";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import escapeStringRegexp from "escape-string-regexp";
import * as React from "react";

export type SearchPageProps = {
  items: string[];
};

const SearchPage: React.FC<SearchPageProps> = (props) => {
  const [keyword, updateKeyword] = React.useState("");
  const searchField: TextFieldProps = {
    onChange: (event) => {
      updateKeyword(event.target.value);
    },
  };
  const filteredItems = props.items.filter((item) => {
    const regex = new RegExp(escapeStringRegexp(keyword));
    return regex.test(item);
  });
  return (
    <Box>
      <TextField {...searchField} />
      <ul>
        {filteredItems.map((item) => {
          return <li key={item}>{item}</li>;
        })}
      </ul>
    </Box>
  );
};

SearchPage.displayName = "SearchPage";

export default SearchPage;

この SearchPage は検索文字列をコンポーネント内に保有しているため、外部からは制御することはできません。これをテストするためには e2e テストをするしかありません。また、filter 関数内にバグがあった場合は実行時エラーとなるため e2e テストが整備されていない状態やデバッグが不十分な場合はユーザーが初めて不具合に気がつくことになります。

これを全単射を満たすようにリファクタリングするには次のようにします。

import Box from "@mui/material/Box";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import * as React from "react";

export type SearchPageProps = {
  searchField: Pick<TextFieldProps, "onChange">;
  items: string[];
};

const SearchPage: React.FC<SearchPageProps> = (props) => {
  return (
    <Box>
      <TextField {...props.searchField} />
      <ul>
        {props.items.map((item) => {
          return <li key={item}>{item}</li>;
        })}
      </ul>
    </Box>
  );
};

SearchPage.displayName = "SearchPage";

export default SearchPage;

State をこのコンポーネントの外側に定義し、与えられた Props に対してコンポーネントの挙動が一意に決まる状態にします(全単射の状態)。

Container/SearchPage.tsx

import * as React from "react";
import escapeStringRegexp from "escape-string-regexp";
import SearchPage, {
  SearchPageProps,
} from "../components/recommendation/SearchPage";

const SearchPageContainer: React.FC = () => {
  const [keyword, updateKeyword] = React.useState("");
  const searchPageProps: SearchPageProps = {
    searchField: {
      onChange: (event) => {
        updateKeyword(event.currentTarget.value);
      },
    },
    items: [
      "C",
      "C++",
      "C#",
      "Python",
      "Ruby",
      "JavaScript",
      "TypeScript",
      "Erlang",
      "Scala",
      "Go",
    ].filter((item) => {
      const regex = new RegExp(escapeStringRegexp(keyword));
      return regex.test(item);
    }),
  };
  return <SearchPage {...searchPageProps} />;
};

export default SearchPageContainer;

ここから得られる知見は配列の数をコンポーネント側で変更しないことです。Props で与えられた数だけコンポーネントにマッピングすることで全単射なコンポーネントを構築できます、故に、数を減らす可能性のある処理、find/filter/reduce を Presentation 層は避けることが望ましいです。

まとめ

React と TypeScript の Tips を紹介しました。ただ Tips を紹介するだけでなく、それがなぜ我々にとって有益なのかを提示し、実装を迷いがない状態で挑めるように整理しました。

今後の展望

現状、口頭やレビュー、ドキュメンテーションによるコードの品質を担保しています。しかしながら相互監視するやり方の場合、人に対する負荷が大きくヒューマンエラーやスケーラビリティーに限界があります。他に違わず、これらは自動化すべき対象であり、少人数でも大規模なシステムを運用できるためのツールを開発することは今後取り組むべき課題です。

より高品質なアプリケーションを構築し、より高い価値をより多く、そして速く顧客に届けることをプロダクトで示していきます。

翌日は谷合の「スキルレベルの違うメンバー間のフロントエンド開発について」です!