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();
};
コンポーネントと結合
先ほど作成したuseUIStackDemoをUIStackComponentと結合します。
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