👏

Reactとts-patternの組み合わせを考える🤔

2024/09/29に公開

紹介すること

Reactにおけるts-patternの使用がもたらすメリットとデメリットを、可読性やパフォーマンスの観点から考察します。
また、ユースケースやデメリットにどのように対処すべきかについても考察します。

Reactとパターンマッチ

Reactは、関数型プログラミングのエッセンスをふんだんに取り入れているUIライブラリだと思います。
宣言的なUIを持ち、純粋性を重視することから、Reactは関数型プログラミングに近い概念を採用しています。

パターンマッチは、関数型言語の一つの機能として知られています。
フロントエンド開発ではビジネスロジックを書く機会が少ないため、パターンマッチは過剰と感じられることがありますが、状態によってUIが変わるReactでは、意外とパターンマッチと相性が良いのではないかと予想しました。

https://ja.react.dev/reference/rules/components-and-hooks-must-be-pure

ts-pattern

ts-patternは、TypeScriptでパターンマッチを利用できるライブラリです。

https://github.com/gvergnaud/ts-pattern

メリット、デメリット

以下の点を考慮して、ts-patternの導入を検討する必要があります。

メリット

  • 高い可読性:宣言的に書けるため、Reactの思想に合致し、可読性が高いです。
  • 条件の網羅性: 条件分岐を漏れなく網羅できます。

デメリット

  • パフォーマンス:外部ライブラリのため、パフォーマンスに懸念があるかもしれません。

これらのポイントについて順に説明します。

メリット - 高い可読性と条件の網羅性

ts-patternを使用すると、コードが宣言的になり、Reactの思想に適した可読性を実現できます。

UIを切り替える例

管理者権限があれば管理者画面を、利用者であればゲスト画面を表示するコンポーネントを考えてみましょう。

通常の条件分岐
type UserRole = 'admin' | 'guest';

const AdminDashboard = () => <h1>Admin Dashboard</h1>;
const GuestDashboard = () => <h1>Guest Dashboard</h1>;

const App = () => {
  // UserRoleをランダムに返す関数、実際はAPIから取得
  const getRandomUserRole = (): UserRole => {
    const roles: UserRole[] = ['admin', 'editor'];
    const randomIndex = Math.floor(Math.random() * roles.length);
    return roles[randomIndex] ?? 'admin';
  };
  const userRole: UserRole = getRandomUserRole(); // 実際はAPIから取得

  return (
    <div>
      {userRole === 'admin' && <AdminDashboard />}
      {userRole === 'guest' && <GuestDashboard />}
    </div>
  );
};

export default App;

ts-patternを用いると、以下のように記述できます。

ts-patternを用いた例
import { match } from 'ts-pattern';

type UserRole = 'admin' | 'editor';

const AdminDashboard = () => <h1>Admin Dashboard</h1>;
const GuestDashboard = () => <h1>Guest Dashboard</h1>;

const App = () => {
  // UserRoleをランダムに返す関数、実際はAPIから取得
  const getRandomUserRole = (): UserRole => {
    const roles: UserRole[] = ['admin', 'guest'];
    const randomIndex = Math.floor(Math.random() * roles.length);
    return roles[randomIndex] ?? 'admin';
  };
  const userRole: UserRole = getRandomUserRole(); // 実際はAPIから取得

  const dashboard = match(userRole)
    .with('admin', () => <AdminDashboard />)
    .with('guest', () => <GuestDashboard />)
    .exhaustive();

  return <div>{dashboard}</div>;
};

export default App;

編集者権限を追加したとき

編集者権限を追加した場合、以下のように書き忘れると、エラーが発生しません。

通常の条件分岐
type UserRole = 'admin' | 'guest' | 'editor'; // guestを追加した

const AdminDashboard = () => <h1>Admin Dashboard</h1>;
const GuestDashboard = () => <h1>Guest Dashboard</h1>;

const App = () => {
  // UserRoleをランダムに返す関数、実際はAPIから取得
  const getRandomUserRole = (): UserRole => {
    const roles: UserRole[] = ['admin', 'guest', 'editor'];
    const randomIndex = Math.floor(Math.random() * roles.length);
    return roles[randomIndex] ?? 'admin';
  };
  const userRole: UserRole = getRandomUserRole(); // 実際はAPIから取得

  return (
    <div>
      {userRole === 'admin' && <AdminDashboard />}
      {userRole === 'guest' && <GuestDashboard />}
// editorのときの表示を書いていないがエラー起きない
    </div>
  );
};

export default App;

しかし、ts-patternを使うと、編集者権限のUIを書き忘れた場合にエラーが発生します

ts-patternを用いた例
import { match } from 'ts-pattern';

type UserRole = 'admin' | 'guest' | 'editor';

const AdminDashboard = () => <h1>Admin Dashboard</h1>;
const GuestDashboard = () => <h1>Guest Dashboard</h1>;

const App = () => {
// UserRoleをランダムに返す関数、実際はAPIから取得
  const getRandomUserRole = (): UserRole => {
    const roles: UserRole[] = ['admin', 'guest', 'editor'];
    const randomIndex = Math.floor(Math.random() * roles.length);
    return roles[randomIndex] ?? 'admin';
  };
  const userRole: UserRole = getRandomUserRole(); // 実際はAPIから取得

  const dashboard = match(userRole)
    .with('admin', () => <AdminDashboard />)
    .with('guest', () => <GuestDashboard />)
    .exhaustive(); // ここでエラーが起きる

  return <div>{dashboard}</div>;
};

export default App;

この様に、条件分岐の不足を検知することができ、条件の網羅性が向上します。

useReducerのswitch文の例

useReducerを使用する場合、アクションが増えると複雑性が増します。
以下はカウント処理の例です。

switch文の例
import React, { useReducer } from 'react';

type State = { count: number };
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; payload: number };

// switch文を使ってreducer処理を書いている
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: action.payload };
    default:
      throw new Error('Unknown action');
  }
};

const Counter: React.FC = () => {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'RESET', payload: 0 })}>
        Reset
      </button>
    </div>
  );
};

export default Counter;


ts-patternを用いると、次のように記述できます。

ts-patternを使った例
import React, { useReducer } from 'react';
import { match } from 'ts-pattern';

type State = { count: number };
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET'; payload: number };

// ts-patternを使ってreducer処理を書いている
const reducer = (state: State, action: Action): State => {
  return match(action)
    .with({ type: 'INCREMENT' }, () => ({ count: state.count + 1 }))
    .with({ type: 'DECREMENT' }, () => ({ count: state.count - 1 }))
    .with({ type: 'RESET' }, ({ payload }) => ({
      count: payload
    }))
    .otherwise(() => {
      throw new Error('Unknown action');
    });
};

const Counter: React.FC = () => {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'RESET', payload: 0 })}>
        Reset
      </button>
    </div>
  );
};

export default Counter;

前述したメリットのように、Actionに新しい値を追加した際に、処理を追加し忘れることを防げます。
加えて、可読性も改善されていると感じました。

その他の機能

ts-patternはこれ以外にも多くの機能を提供しています。
高機能なif文のように利用でき、型チェック(P.string、P.nullish)や文字数による分岐(P.string.length)など、さまざまな条件での処理が可能です。
詳細についてはドキュメントを参照すると、更にテクニカルな書き方ができると思います。

https://github.com/gvergnaud/ts-pattern

このようにフロントエンドにts-patternを導入して、メリットを享受することができました。

デメリット - パフォーマンス

外部ライブラリなので、パフォーマンスに懸念があるのではないかと考え、調査をしました。

console.timeでの時間計測

const types = ['loading', 'error', 'success'];

// 適当な10000行のデータを生成
const data = Array.from({ length: 10000 }, () => {
  const randomType = types[Math.floor(Math.random() * types.length)];
  return { type: randomType };
});

// ts-patternによる処理
console.time('ts-pattern processing');
data.map((item) =>
  match(item)
    .with({ type: 'loading' }, () => 'Loading...')
    .with({ type: 'error' }, () => 'Error occurred')
    .with({ type: 'success' }, () => 'Success')
    .otherwise(() => 'Unknown state')
);
console.timeEnd('ts-pattern processing');

// switch文による処理
console.time('switch processing');
data.map((item) => {
  switch (item.type) {
    case 'loading':
      return 'Loading...';
    case 'error':
      return 'Error occurred';
    case 'success':
      return 'Success';
    default:
      return 'Unknown state';
  }
});
console.timeEnd('switch processing');

パフォーマンス比較結果

  • ts-pattern 処理時間: 3.72 ms
  • switch 処理時間: 0.14 ms

switch文の方が約25倍以上早い結果となりました。

また、useReducerでも以下のようにパフォーマンスに差が出ました:

  • switch: 0.006 ms
  • ts-pattern: 0.39 ms

パフォーマンスの指標として、RAILモデルという考え方があり、レスポンスの速度は100ms以内、ロードの速度は1000ms以内に抑えるべきとされています。
よって、致命的なパフォーマンスの劣化は見られないと思っています。

デメリットの対処法

useMemoによるパフォーマンス最適化

Reactの公式ドキュメントによると、useMemoは1msを超えた場合に検討することが推奨されています。
そこで、useMemoを使って結果をキャッシュします。

https://ja.react.dev/reference/react/useMemo

  const types = ['loading', 'error', 'success'];

  // 適当な10000行のデータを生成
  const data = useMemo(
    () =>
      Array.from({ length: 10000 }, () => {
        const randomType = types[Math.floor(Math.random() * types.length)];
        return { type: randomType };
      }),
    []
  );

  // ts-patternによる処理
  useMemo(() => {
    console.time('ts-pattern processing');
    const result = console.time('ts-pattern processing');
    data.map((item) =>
      match(item)
        .with({ type: 'loading' }, () => 'Loading...')
        .with({ type: 'error' }, () => 'Error occurred')
        .with({ type: 'success' }, () => 'Success')
        .otherwise(() => 'Unknown state')
    );
    console.timeEnd('ts-pattern processing');
    return result;
  }, [data]);

  // switch文による処理
  useMemo(() => {
    console.time('switch processing');
    const result = data.map((item) => {
      switch (item.type) {
        case 'loading':
          return 'Loading...';
        case 'error':
          return 'Error occurred';
        case 'success':
          return 'Success';
        default:
          return 'Unknown state';
      }
    });
    console.timeEnd('switch processing');
    return result;
  }, [data]);

これで、dataに変更がない場合は、レンダリングされません。当たり前ですが、初回レンダリングやdataの値が変わったときはこのパフォーマンスの問題はクリアできていませんし、ts-patternを使用することにより、キャッシュ化するエンジニアの労力がかかってくることも考慮しないといけません。

ts-patternの網羅性チェックを自作する

ts-patternの機能で一番有用なのは、網羅性チェックだと感じています。
網羅性チェックをswitch文で自作して、パフォーマンスを据え置きで実装したいと思います。

type UserRole = 'admin' | 'guest' | 'editor';

const AdminDashboard = () => <h1>Admin Dashboard</h1>;
const GuestDashboard = () => <h1>Guest Dashboard</h1>;
const EditorDashboard = () => <h1>Editor Dashboard</h1>;

const App = () => {
  // UserRoleをランダムに返す関数、実際はAPIから取得
  const getRandomUserRole = (): UserRole => {
    const roles: UserRole[] = ['admin', 'guest', 'editor'];
    const randomIndex = Math.floor(Math.random() * roles.length);
    return roles[randomIndex] ?? 'admin';
  };
  const userRole: UserRole = getRandomUserRole(); // 実際はAPIから取得

  return (
    <div>
      {(() => {
        switch (userRole) {
          case 'admin':
            return <AdminDashboard />;
          case 'guest':
            return <GuestDashboard />;
          case 'editor':
            return <EditorDashboard />;
          default:
            // ここで網羅性チェックをしている
            const exhaustiveCheck: never = userRole;
            return exhaustiveCheck;
        }
      })()}
    </div>
  );
};

export default App;

defaultに到達するのは、userRoleが 'admin', 'guest', 'editor'のいずれにも一致しない場合です。
ここで、never型を利用して網羅性チェックをしています。

switch文なので、パフォーマンスは維持しつつ、網羅性チェックができます

ts-patternを控えたほうがいいケース

紹介したパフォーマンスの差は10000件の検証であり、致命的なパフォーマンスの劣化があるわけではなくほとんどのWebアプリケーションには使えそうだと感じます。

パフォーマンスにシビアなアプリ、画面

ts-patternがswitch文よりもパフォーマンスが悪いのは事実です。
ですが、Webアプリケーションのパフォーマンスが低下する原因は様々であり、もしパフォーマンスチューニングが必要であれば、別にクリティカルな原因があると思います。

以下のサイトなどでアプリのスピードを計測してみることが大事だと思います。
https://pagespeed.web.dev/

パフォーマンスはトレードオフです。

可読性 + 網羅性チェック > キャッシュ化のエンジニアの労力 + パフォーマンスと考える必要があると思います。

シンプルな条件分岐

例えば、2択の分岐には三項演算子を使用した方が可読性が良いでしょう。

// 三項演算子
isSmaple ? "sample" : "notSample"

// ts-pattern
match(isSample)
  .with(true, () => "sample")
  .with(false, () => "notSample")
  .exhaustive();

ユースケース

画面、処理ごとでts-patternを使ったほうが良いのかを考えるマインドで実装する必要があると思います

条件分岐に拡張性がある場合

ts-patternでは、前述したように漏れを検知する機能があるので、あり得る型を定義しさえすれば、処理漏れを仕組み化でなくせます

具体的なユースケース

  • 3択以上の値による条件分岐(API,props,stateなど)
  • useReducerでの処理の条件分岐

総論

ts-patternはフロントエンドでもメリットを享受できると感じましたが、ややオーバースペック気味でif文やswitch文で事足りるとも思いました。

繰り返しますが、可読性 + 網羅性チェック > キャッシュ化のエンジニアの労力 + パフォーマンスを念頭に置いて、実装が必要だと感じます。
致命的なパフォーマンスの劣化は見られないので、多くのアプリでは可読性や網羅性チェックを優先してts-patternの導入ができると思いました

エックスポイントワン技術ブログ

Discussion