【React】設計をシンプルにする試み

2023/09/05に公開

これは懺悔でもあるのだが、最近はより依存性が薄く、シンプルで移植可能な設計をしようという気持ちになった。昨今のApp routerの事情も踏まえて。

依存性が低いと何が嬉しいか

長期間保守されるプロジェクトの場合、人の入れ替わりが激しくなりうる。当然誰もが四六時中フロントエンドの情報をチェックしている訳ではなく、Reactの事情など知らないメンバーや技術トレンドに詳しくない社内SEが保守する可能性は大いにある。

短期間で作り捨てのプロモーション用LP等も、できるだけ使い回せる環境を整えると役に立つはず。

技術トレンドに振り回されない方法

近頃はNext.jsで制作していたサイトをpages routerからapp routerに変更した。良し悪しはあったけど結局ページとheadタグ(と言えば良いのか)に関わる部分が変わっただけで、全領域を目を凝らして移行したことはない。

フロントエンドで最近流行っているアーキテクチャはそういうものが目立つ。

  • Next.js (App router): 不必要なJavaScriptを取り除く
  • Tailwind CSS: 低レベルなユーティリティクラスで、CSS in JSと同等のトークンベースを実現させる

どちらもAPIの書き方の好みは分かれるが、そこはさほど問題ではなく必要な技術が相応しいレイヤーに収まっている美しさが評価すべきポイントだと思う。本来はHTMLもCSSもJavaScriptパワーで捏ねくり回すものでは無かった。

プログラマ側も上記のようなことを意識してコーディングできるようになることが望ましい。

依存性の低いコード

依存性が低く、移植可能性が高いコードはどう書けば良いのだろうか。闇雲にutilsディレクトリにファイルを量産するのではなく、そのレイヤーが適切か?を常に問うことである。

例: コンポーネント

ナビゲーションに使えそうな、リンク先が現在地と同じときに別個のスタイリングを注入できるunstyledなコンポーネントを考える。いくらか実装のパターンはあるが、特徴はハッキリしている。

(1) 高依存

ReactのUIライブラリでは稀によくある形式だが、初見ではぎょっとする。

const isMatchFirstPath = (base: string, target: string) => {
  return base.split('/')[1] === target.split('/')[1];
};

type ActiveLink1Props = Omit<React.ComponentProps<typeof Link>, 'className'> & {
  className?: (isActive?: boolean) => string;
};

export function ActiveLink1(props: ActiveLink1Props) {
  const { href, className, ...restProps } = props;
  const pathname = usePathname();

  const isActive = isMatchFirstPath(
    pathname,
    typeof href === 'string' ? href : href.pathname || '',
  );

  return <Link href={href} className={className?.(isActive)} {...restProps} />;
}

// <ActiveLink1
//   href={href}
//   className={(isActive) =>
//     isActive ? 'text-primary-600' : 'text-gray-foreground'
//   }
// >
//   {label}
// </ActiveLink1>;

(2) 高依存

元々のタグにない属性が生えているので、JSXで捏ねくり回した別物感が強い。

type ActiveLink2Props = React.ComponentProps<typeof Link> & {
  activeClassName?: string;
};

export function ActiveLink2(props: ActiveLink2Props) {
  const { href, className, activeClassName, ...restProps } = props;
  const pathname = usePathname();

  const isActive = isMatchFirstPath(
    pathname,
    typeof href === 'string' ? href : href.pathname || '',
  );

  return (
    <Link
      href={href}
      className={clsx(className, isActive && activeClassName)}
      {...restProps}
    />
  );
}

// <ActiveLink2
//   href={href}
//   className="text-gray-foreground"
//   activeClassName="text-primary-600"
// >
//   {label}
// </ActiveLink2>;

(3) 低依存

良いね!ほとんどHTMLとCSSの知識だけでOK👏

type ActiveLink3Props = React.ComponentProps<typeof Link>;

export function ActiveLink3(props: ActiveLink3Props) {
  const { href, ...restProps } = props;
  const pathname = usePathname();

  const isActive = isMatchFirstPath(
    pathname,
    typeof href === 'string' ? href : href.pathname || '',
  );

  return (
    <Link href={href} data-active={isActive || undefined} {...restProps} />
  );
}

// <ActiveLink3
//   href={href}
//   className="text-gray-foreground data-[active]:text-primary-600"
// >
//   {label}
// </ActiveLink3>;

もちろんテキスト等その他の属性も柔軟に変更したいなら(1)を拡張してRender Propsにした方が良いし、入力補完付きで分かりやすいのは(2)の方針になる。しかしスタイリングのみの場合、最近は(3)の形式を好んでいる。

例: Hooks

配列操作で楽をしたく、このようなReact Hooksを作成したことがある。

import { useState } from 'react';

export function useToggleArray<T>(initialValue: T[]) {
  const [array, setArray] = useState<T[]>(initialValue);

  const toggle = (selectedValue: T) => {
    const found = array.find((value) => value === selectedValue);
    if (found) {
      setArray((prev) => prev.filter((value) => value !== selectedValue));
    } else {
      setArray((prev) => [...prev, selectedValue]);
    }
  };

  return [array, toggle] as const;
}

// const [array, toggleArray] = useToggleArray<string>(['foo', 'bar'])
// ...
// onClick={() => toggleArray('foo')}

しかしHooksなど全くの不要であり、次の実装の方があるべき姿だと感じる。

export function toggleArray<T>(array: T[], selectedValue: T) {
  const found = array.find((value) => value === selectedValue);
  if (found) {
    return array.filter((value) => value !== selectedValue);
  } else {
    return [...array, selectedValue];
  }
}

// const [array, setArray] = useState<string[]>(['foo', 'bar'])
// ...
// onClick={() => setArray((prev) => toggleArray(prev, 'foo'))}

アンチ・ライブラリ

多くのライブラリには設計思想を常々助けられているが、実務ではむしろできるだけライブラリは使うな!と言いたい。もちろん全く現実的ではなく、そういう意気込みを持って欲しい。

なぜライブラリを増やすのが問題かというと、これらが無視できない程度になるからだ。

  • (a) 本当にライブラリのパワーに頼るべき部分とそうでない部分の区別が付きづらくなる
  • (b) 必要性の薄いライブラリは基礎力の低いビギナーを戸惑わせる
  • (c) 厚いライブラリ・薄いライブラリに関わらず、パッケージ更新のチェックは必須

悪習の例としては、まず思考停止でclsxやreact-useとか入れるのを辞めて欲しい。たかが平易な条件分岐しかないclassNameの結合のために、テクニカルに何重にも書き方のあるパッケージを使う習慣ははっきり言って不要である。次のようなコードを放り込めば済む。

type ClassInput = string | number | boolean | null | undefined;

export default function clsx(...inputs: ClassInput[]) {
  const classes = inputs.filter(Boolean).join(' ');
  // Tailwind使うならtwMergeも適用させる
  return twMerge(classes);
}

react-useも言ってしまえばReact Hooks版のjQueryやlodashであり、数個の簡単なユーティリティを楽したいがために脳死で大量のモジュールを入れることはあまり賢くない。逆に単体の機能を提供するHooksのパッケージなら、目的が明確になり問題はないと思う。(例: react-intersection-observer)

あまり薄いライブラリの類でない部分にも思うところがあり、zodとRHFはここまでデファクトスタンダードなのかと疑っている。恐らく大半のユースケースでは、Uncontrolled Formのエレガントなレンダリング抑制よりも明瞭なControlled Formと自前のバリデーションで済むのではないか。

import React, { useState } from 'react';

interface RegisterFormValues {
  name: string;
  email: string;
}

type InputError = {
  type: string;
} | null;

export function validate(values: RegisterFormValues) {
  const errors: { [key in keyof RegisterFormValues]: InputError | null } = {
    name: null,
    email: null,
  };

  if (!values.name) {
    errors.name = { type: 'Reqired' };
  }

  if (!values.email) {
    errors.email = { type: 'Reqired' };
  } else if (!isEmailRegex(values.email)) {
    errors.email = { type: 'Invalid email' };
  }

  return errors;
}

function RegisterForm() {
  const [values, setValues] = useState<RegisterFormValues>({
    name: '',
    email: '',
  });
  const errors = validate(values);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValues((prev) => ({ ...prev, [e.target.name]: e.target.value }));
  };

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // ...
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="name" value={values.name} onChange={handleChange} />
      <span>{errors.name?.type}</span>
      <input type="email" name="email" value={values.email} onChange={handleChange} />
      <span>{errors.email?.type}</span>
      <button>OK</button>
    </form>
  );
}

このコードでは恐らく初回入力からエラーメッセージが出るのでUXが良くないが、イベント処理を分かっていれば修正も容易いだろう。

Reactが公式からControlled Form推奨という文言を消したことは記憶に新しいが、それでも個人的に今のところは後者を好み続けるつもりだ。メンテナンスが途絶え気味のFormikに想いを寄せつつ...

Discussion