😽

UI Stack with TS-Pattern

に公開

目的

Reactで、UI Stackを意識したコンポーネントを実装する方法を説明します。

UI Stack とは

UI Stackとは、UIの考慮すべき5つの状態の事です。

https://www.scotthurff.com/posts/why-your-user-interface-is-awkward-youre-ignoring-the-ui-stack/

以下の5つの状態を示します。

  • Ideal State
  • Empty State
  • Error State
  • Partial State
  • Loading State

UI Stackの記事をもとに制作されたFigmaです。こちらで各Stateに対してUIがイメージしやすくなると思います。

補足

Errorを使用するとエラークラスと誤認識する可能性があるため以降はFailureとして扱います。

UI Stack の枠組みを Reactで定義

まずは、コンポーネントと型の定義です。
各コンポーネントにFigmaのリンクがあると見返しやすくなると思います。

Step1: コンポーネントと型の定義
type IdealProps = {
  __uiStackType: 'ideal';
};

type EmptyProps = {
  __uiStackType: 'empty';
};

type FailureProps = {
  __uiStackType: 'failure';
};

type PartialProps = {
  __uiStackType: 'partial';
};

type LoadingProps = {
  __uiStackType: 'loading';
};

type Props =
  | IdealProps
  | EmptyProps
  | FailureProps
  | PartialProps
  | LoadingProps;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-210&t=5yE8KdqCzyLIPFX9-4)
 */
const Ideal: React.FC<IdealProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-38&t=5yE8KdqCzyLIPFX9-4)
 */
const Empty: React.FC<EmptyProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=25-149&t=5yE8KdqCzyLIPFX9-4)
 */
const Failure: React.FC<FailureProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-263&t=5yE8KdqCzyLIPFX9-4)
 */
const Partial: React.FC<PartialProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-314&t=5yE8KdqCzyLIPFX9-4)
 */
const Loading: React.FC<LoadingProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=26-366&t=5yE8KdqCzyLIPFX9-4)
 */
const UIStackComponent: React.FC<Props> = () => <></>;

__uiStackTypeは、TS-Patternで使用します。
TS-Patternとは、TypeScript向けのパターンマッチンライブラリです。判別可能なユニオン型 (discriminated union)と相性が良く簡潔なコードで判別可能です。

Step2: TS-Patternでハンドリング
/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=26-366&t=5yE8KdqCzyLIPFX9-4)
 */
const UIStackComponent: React.FC<Props> = (props) =>
  match(props)
    .with({ __uiStackType: 'ideal' }, () => <></>)
    .with({ __uiStackType: 'empty' }, () => <></>)
    .with({ __uiStackType: 'failure' }, () => <></>)
    .with({ __uiStackType: 'partial' }, () => <></>)
    .with({ __uiStackType: 'loading' }, () => <></>)
    .exhaustive();
Step3: 各状態のコンポーネントと紐づけ
/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=26-366&t=5yE8KdqCzyLIPFX9-4)
 */
const UIStackComponent: React.FC<Props> = (props) =>
  match(props)
    .with({ __uiStackType: 'ideal' }, (idealProps) => <Ideal {...idealProps} />)
    .with({ __uiStackType: 'empty' }, (emptyProps) => <Empty {...emptyProps} />)
    .with({ __uiStackType: 'failure' }, (failureProps) => (
      <Failure {...failureProps} />
    ))
    .with({ __uiStackType: 'partial' }, (partialProps) => (
      <Partial {...partialProps} />
    ))
    .with({ __uiStackType: 'loading' }, (loadingProps) => (
      <Loading {...loadingProps} />
    ))
    .exhaustive();

コンポーネント全体像

import { match } from 'ts-pattern';

type IdealProps = {
  __uiStackType: 'ideal';
};

type EmptyProps = {
  __uiStackType: 'empty';
};

type FailureProps = {
  __uiStackType: 'failure';
};

type PartialProps = {
  __uiStackType: 'partial';
};

type LoadingProps = {
  __uiStackType: 'loading';
};

type Props =
  | IdealProps
  | EmptyProps
  | FailureProps
  | PartialProps
  | LoadingProps;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-210&t=5yE8KdqCzyLIPFX9-4)
 */
const Ideal: React.FC<IdealProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-38&t=5yE8KdqCzyLIPFX9-4)
 */
const Empty: React.FC<EmptyProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=25-149&t=5yE8KdqCzyLIPFX9-4)
 */
const Failure: React.FC<FailureProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-263&t=5yE8KdqCzyLIPFX9-4)
 */
const Partial: React.FC<PartialProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=24-314&t=5yE8KdqCzyLIPFX9-4)
 */
const Loading: React.FC<LoadingProps> = () => <></>;

/**
 * [Figma](https://www.figma.com/design/GeCGcVwD1Lq8Q1YBFTKxU1/The-UI-stack-in-product-design-(Community)-(Community)?node-id=26-366&t=5yE8KdqCzyLIPFX9-4)
 */
const UIStackComponent: React.FC<Props> = (props) =>
  match(props)
    .with({ __uiStackType: 'ideal' }, (idealProps) => <Ideal {...idealProps} />)
    .with({ __uiStackType: 'empty' }, (emptyProps) => <Empty {...emptyProps} />)
    .with({ __uiStackType: 'failure' }, (failureProps) => (
      <Failure {...failureProps} />
    ))
    .with({ __uiStackType: 'partial' }, (partialProps) => (
      <Partial {...partialProps} />
    ))
    .with({ __uiStackType: 'loading' }, (loadingProps) => (
      <Loading {...loadingProps} />
    ))
    .exhaustive();

よくあるUIを実装する

APIでデータを取得して表示する流れを、先ほど作ったUIStackの枠組みに適用します。

Idealコンポーネントの実装

IdealPropsに以下のような画面に表示する値を定義します。

type IdealProps = {
  __uiStackType: 'ideal';
  data: {
    items: { id: string; name: string }[];
  };
};

Idealコンポーネントは、以下のように実装します。

const Ideal: React.FC<IdealProps> = (props) => (
  <ul>
    {props.data.items.map((item) => (
      <li key={item.id}>{item.name}</li>
    ))}
  </ul>
);

以下のような全ての状態を考慮したPropsを定義する必要がなくなります。

// データ取得時, 成功時, 失敗時
type Props = {
  data?: {
    items?: { id: string; name: string }[];
  };
  error?: unknown;
};

以下のような全ての状態を考慮したComponentを定義する必要がなくなります。

const Component: React.FC<Props> = (props) => {
  if (props?.error) {
    return <>取得失敗</>;
  }

  if (!props?.data) {
    return <>取得中</>;
  }

  return (
    <ul>
      {props.data.items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
};

typeを別ファイルに

UIStackのコンポーネントと、後述するhookで使用する為別ファイルに移動します。
以下のような構成です。

  • src/UIStack.tsx
  • src/type.ts

hooksの実装

TanStack Queryを使用したhookです。SWRなどを使用しても同等の実装になるはずです。
isXxxをそのまま使用せずに、__uiStackTypeに割り当てることで、ユニオン型として使用可能です。

import { useQuery } from '@tanstack/react-query';
import { match, P } from 'ts-pattern';
import * as type from './type';

const partialCnt = 10;

type UseUIStackDemo = () => type.Props;
export const useUIStackDemo: UseUIStackDemo = () => {
  const res = useQuery({
    queryKey: ['UIStack'],
    queryFn: () =>
      fetch('https://api/demo/uistack').then<{
        items: { id: number; name: string }[];
      }>((res) => res.json()),
  });

  return match<typeof res, type.Props>(res)
    .with({ isPending: true }, () => ({
      __uiStackType: 'loading',
    }))
    .with({ isError: true }, () => ({
      __uiStackType: 'failure',
    }))
    .with(
      { isSuccess: true, data: { items: P.when((x) => !x.length) } },
      () => ({
        __uiStackType: 'empty',
      }),
    )
    .with(
      {
        isSuccess: true,
        data: { items: P.when((x) => x.length <= partialCnt) },
      },
      () => ({
        __uiStackType: 'partial',
      }),
    )
    .with({ isSuccess: true }, (r) => ({
      __uiStackType: 'ideal',
      data: r.data,
    }))
    .exhaustive();
};

コンポーネントと結合

先ほど作成したuseUIStackDemoUIStackComponentと結合します。

import * as type from './type';
import { useUIStackDemo } from './useUIStackDemo';

// ~ 省略

const UIStackComponent: React.FC<type.Props> = (props) =>
  match(props)
    .with({ __uiStackType: 'ideal' }, (idealProps) => <Ideal {...idealProps} />)
    .with({ __uiStackType: 'empty' }, (emptyProps) => <Empty {...emptyProps} />)
    .with({ __uiStackType: 'failure' }, (failureProps) => (
      <Failure {...failureProps} />
    ))
    .with({ __uiStackType: 'partial' }, (partialProps) => (
      <Partial {...partialProps} />
    ))
    .with({ __uiStackType: 'loading' }, (loadingProps) => (
      <Loading {...loadingProps} />
    ))
    .exhaustive();

const UIStack: React.FC = () => <UIStackComponent {...useUIStackDemo()} />;

実装の全体像

型の定義
type IdealProps = {
  __uiStackType: 'ideal';
  data: {
    items: { id: number; name: string }[];
  };
};

type EmptyProps = {
  __uiStackType: 'empty';
};

type FailureProps = {
  __uiStackType: 'failure';
};

type PartialProps = {
  __uiStackType: 'partial';
};

type LoadingProps = {
  __uiStackType: 'loading';
};

export type Props =
  | IdealProps
  | EmptyProps
  | FailureProps
  | PartialProps
  | LoadingProps;
コンポーネントの実装
import * as type from './type';
import { useUIStackDemo } from './useUIStackDemo';

/**
 * 適切なFigmaへのリンク
 */
const Ideal: React.FC<IdealProps> = (props) => (
  <ul>
    {props.data.items.map((item) => (
      <li key={item.id}>{item.name}</li>
    ))}
  </ul>
);

/**
 * 適切なFigmaへのリンク
 */
const Empty: React.FC<type.EmptyProps> = () => <></>;

/**
 * 適切なFigmaへのリンク
 */
const Failure: React.FC<type.FailureProps> = () => <></>;

/**
 * 適切なFigmaへのリンク
 */
const Partial: React.FC<type.PartialProps> = () => <></>;

/**
 * 適切なFigmaへのリンク
 */
const Loading: React.FC<type.LoadingProps> = () => <></>;

/**
 * 適切なFigmaへのリンク
 */
const UIStackComponent: React.FC<type.Props> = (props) =>
  match(props)
    .with({ __uiStackType: 'ideal' }, (idealProps) => <Ideal {...idealProps} />)
    .with({ __uiStackType: 'empty' }, (emptyProps) => <Empty {...emptyProps} />)
    .with({ __uiStackType: 'failure' }, (FailureProps) => (
      <Failure {...FailureProps} />
    ))
    .with({ __uiStackType: 'partial' }, (partialProps) => (
      <Partial {...partialProps} />
    ))
    .with({ __uiStackType: 'loading' }, (loadingProps) => (
      <Loading {...loadingProps} />
    ))
    .exhaustive();

const UIStack: React.FC = () => <UIStackComponent {...useUIStackDemo()} />;
hookの実装
import { useQuery } from '@tanstack/react-query';
import { match, P } from 'ts-pattern';
import * as type from './type';

const partialCnt = 10;

type UseUIStackDemo = () => type.Props;
export const useUIStackDemo: UseUIStackDemo = () => {
  const res = useQuery({
    queryKey: ['UIStack'],
    queryFn: () =>
      fetch('https://api/demo/uistack').then<{
        items: { id: number; name: string }[];
      }>((res) => res.json()),
  });

  return match<typeof res, type.Props>(res)
    .with({ isPending: true }, () => ({
      __uiStackType: 'loading',
    }))
    .with({ isError: true }, () => ({
      __uiStackType: 'failure',
    }))
    .with(
      { isSuccess: true, data: { items: P.when((x) => !x.length) } },
      () => ({
        __uiStackType: 'empty',
      }),
    )
    .with(
      {
        isSuccess: true,
        data: { items: P.when((x) => x.length <= partialCnt) },
      },
      () => ({
        __uiStackType: 'partial',
      }),
    )
    .with({ isSuccess: true }, (r) => ({
      __uiStackType: 'ideal',
      data: r.data,
    }))
    .exhaustive();
};

Discussion