ikuma-t.

検索

Make Impossible States Impossibleを意識してReactのPropsを設計する

このブログ記事は下記のLT登壇資料を文字起こし(?)したものです。


Reactが関数型プログラミングの影響を受けているということをきっかけに、最近 1 は関数型プログラミングを少しずつ学んでいます。

関数型プログラミングそのものにももちろん興味はありつつ、日常のプログラミングで使えるエッセンスとしても何か学びはないか、という視点で向き合っています。 いろいろとやっていく中で、「Make Impossible States Impossible」という考えを知りました 。「不可能な状態が起こらないように型/インターフェイスを設計しよう」という意味です。

考え方自体はAPIの設計において汎用的に使える指針であり、フロントエンドに関連のある領域だと、Production Ready GraphQLKent C. Dodds氏のブログ記事で取り上げられていました。

Make Impossible States Impossible とは

繰り返しになりますが、Make Impossible States Impossible、あるいは Make Illegal States Unrepresentable は「不可能な状態が起こらないように型/インターフェイスを設計しよう」という設計思想です。

関数型プログラミング言語のコミュニティを出自とする設計思想で、TypeScriptやReactの界隈でも何度か取り上げられているようです。少し前に日本語訳が発売された関数型ドメインモデリングでも「Making Illigal States Unrepresentable In Our Domain」というセクションで紹介されています。

具体的な出典については、Appendixに掲載しましたので、そちらをご参照ください。

Reactでの実践例

ここからはReactのコードを例に、Make Impossible States Impossibleの適用を見てみます。

単一のプロパティでの例はさほど問題にならない

たとえばprimarysecondary、2つのスタイルを持つボタンコンポーネントを考えます。

実装パターンの1つはそれぞれをスイッチ的なフラグで定義することです(2値しかないと、1つのフラグで管理することが可能ですが、ここではサンプルのためご容赦ください)。

type Props = {
primary?: boolean;
secondary?: boolean;
}
<Button primary /> // primary
<Button secondary /> // secondary

これは一見良さそうですが、少し考えるとprimarysecondaryを同時に指定することができてしまいます。これは不整合な状態を引き起こします。

<Button primary secondary /> // ??

この例では、primarysecondaryのそれぞれが2つの値を取るため、合計で4つの状態が考えられます。このように、プロパティの数が増えると、「プロパティが取りうる値のパターン」と「プロパティの数」の積だけ状態が増えてしまい、結果として不正な状態が発生しやすくなります。

これを解消するための方法の1つは、Union型を使ってプロパティを排他的に定義することです。

type Props = {
variant: 'primary' | 'secondary';
}
<Button variant="primary" />]
<Button variant="secondary" />

variant2primarysecondaryのどちらかを取るため、不正な状態が発生しません。また取りうる状態も2つ(取りうる値の組み合わせの数の和)になりました。

この例のようにvariantでスタイルを定義するパターンは多くのUIライブラリが同様に実装していることもあり、割と当たり前になっていると個人的には感じています3

そのため、単一のプロパティを使った状態についてはMake Impossible States Impossibleも自然に実現されることが多いでしょう。

複数のプロパティでの例

問題となるのは複数のプロパティの例です。コンポーネントの状態に作用するプロパティが複数ある場合、「それぞれのプロパティが取りうる値の数」と「プロパティの数」の積だけ、発生しうる状態が増えてしまいます。


ここで実例として、Empty Stateコンポーネントを考えます。Empty Stateコンポーネントは、データが存在しない場合の統一的な見た目を提供するコンポーネントです。次の画像のような見た目をしています。

ブログ記事がない場合に表示されるEmpty Stateコンポーネントとその部品の解剖図。白背景に、びっくりマークのアイコン、「まだブログ記事がありません」というtitle、「記事を作成するとここに表示されます」というdescription、「記事を書く」というボタンがある。ボタンの色は青色で、コンポーネントの説明として、ボタンのテキストはbuttonText、イベントハンドラはonClick、ボタンのスタイルはprimary、サイズはmdで表現されるとある。

このコンポーネントのPropsは、シンプルに次のように定義できます。

type Props = {
title: string;
description: string;
buttonText: string;
onClick: () => void;
}

初期時点では新規作成ボタンだけが要件としてあり、すべてのプロパティは必須です。この時点では特に問題はありません。

ここで要件として、新たに「アプリ内で新規リソースを作成するわけではない場合、ボタンをリンクのスタイルで表示したい」という要件が追加されたとします。

Empty Stateコンポーネントのボタンがリンク仕様になった図。ボタン部分が青枠・白背景のボタンになり、外部リンクを示すアイコンがボタンのテキストの横に表示されている。
type Props = {
title: string;
description: string;
buttonText: string;
buttonText?: string;
onClick: () => void;
onClick+: () => void;
/* リンクのテキスト。これを指定するとリンクを示すアイコンが表示されます */
linkText?: string;
/* 外部リンクかどうか。アイコンが変わるのと別タブで開く挙動が追加されます */
isExternalLink?: boolean;
/* リンク先URL。これを指定するとリンクが有効になります */
href?: string;
}

追加の変更だけで要件のスタイルを実現できました。しかしここには問題があります。ここまでで述べてきたように、「linkTextを指定しているが、hrefを指定していない」「buttonTexthrefが同時に指定してある」など、不正な状態が発生してしまいます。

Discriminated Unionによる解決

解決策の1つは、TypeScript の Discriminated Union を用いて、状態を排他的に定義することです。

type AddResourceButtonProps = {
type: "button"
buttonText: string;
onClick: () => void;
}
type AddResourceLinkProps = {
type: "link"
linkText: string;
href: string;
isExternalLink?: boolean;
}
type Props = {
title: string;
description: string;
addResourceUIProps: AddResourceButtonProps | AddResourceLinkProps;
}

このサンプルでは、typeがタグとしての機能を果たしているため、type="button"の時にはAddResourceButtonPropsのプロパティが、type="link"の時にはAddResourceLinkPropsのプロパティだけが指定できるようになります。

Compositionによる解決

もう1つの解決策は、Compositionを使うことです。Empty Stateコンポーネントが内包するButtonのスタイルが複数あることがそもそもの複雑さの原因なので、これを分離することで問題を解決します。

渡された関数、ここではButtonコンポーネントを呼び出すことだけを任せます。

type Props = PropsWithChildren<{
title: string;
description: string;
}>

ボタンのスタイルはchildrenに渡すButtonコンポーネント側で定義します。

/* Buttonのパターン */
<EmptyStatePanel title="..." description="...">
<Button variant="primary" size="md" onClick={createPost}>
記事を書く
</Button>
</EmptyStatePanel>
/* Linkのパターン */
<EmptyStatePanel title="..." description="...">
<Button variant="secondary" size="lg" asChild>
<Link href={supportPageURL} external>
サポートページへ
</Link>
</Button>
</EmptyStatePanel>
/* Linkのパターン(LinkButtonをスタイル使い回しのために別途用意する */
const LinkButton: FC<PropsWithChildren<{ href: string }>> = ({ href, children }) => {
return (
<Button variant="secondary" size="lg" asChild>
<Link href={href} external>
{children}
</Link>
</Button>
);
}
<EmptyStatePanel title="..." description="...">
<LinkButton href={supportPageURL}>
サポートページへ
</LinkButton>
</EmptyStatePanel>

Buttonに関する状態はButtonコンポーネント側で管理されるため、EmptyStatePanelコンポーネントでは先ほどのような不整合な状態が発生しません(前提として、Buttonコンポーネントが不正な状態が定義できないようになっている必要がありますが)。

既存コンポーネントの変更の際には状態の増加に気をつける

新規でコンポーネントを作成する際には、組み合わせの数が少ないか、組み合わせ自体があっても実装者がすべてのパターンを意識して設計するため、自然とMake Impossible States Impossibleを満たした設計になりやすいと感じます。

反対に既存のコンポーネントに対して変更を加える際には、新しく追加しようとしている状態だけにフォーカスが向いているために、不正な状態が発生しやすいと感じます。

特に既存の汎用コンポーネントを修正する際には、既存の構造に引っ張られることもあり、isFooのようなフラグ的なPropsを生やす引力が働きやすいのもかもしれません。

フラグが1個増えたら、単体で状態が2個増え、最終的に同じ箇所に影響するPropsの数の2倍分、考えうる状態が増えます。そのため、既存のコンポーネントに対して変更を加える際には、状態の増加に気をつけることが重要です。

まとめ

  • Make Impossible States Impossibleは、不正な状態が発生しないように型/インターフェイスを設計する考え方。
  • API 設計においても、React コンポーネントの Props 設計においても適用できる。React では Discriminated Union や Composition を使うことで実現できる。
  • 特に既存コンポーネントを実装する際には、既存実装の引力が働きやすいことと、実装対象にフォーカスしていることが多いので、新しく追加する実装が不正な状態を引き起こさないかを意識することが重要。

React における Make Impossible States Impossible の考え方について、簡単な例を交えて紹介しました。

TypeScriptの文脈だと、最近はTSバックエンドで語られることご多い気がしますが、冒頭で述べた通りAPI設計で汎用的に使える考え方なので、ReactのProps設計にも適用できます。 実践方法である Discriminated Union や Composition については、React や TypeScript では基本的なものです。重要なのはこれらのテクニックというより、これらを適用するための1つの指針としての Make Impossible States Impossible の考え方を意識することだと感じます。

今回挙げた例では「単一責任の原則」「不要な結合関係を排除する」などさまざまな視点からもアプローチができると思いますので、あくまで設計時に使える1つの視点として参考にしていただければと思います。

Appendix

Make Impossible States Impossible / Make Illegal States Unrepresentable について言及された記事・動画

私が調べた範囲で記載します。2011年くらいから言及されていて、2016-2019あたりの記事が多い印象です。

XState と Zag.js と状態と

上記のリストの中に、状態管理ライブラリのXStateの作者による動画がありました。

XStateはステートマシンを中心に据えた状態ライブラリで、状態と遷移を明示的に定義することで、不正な状態を排除することができます。

さらにこの XState を内包した Zag.js は Chakra UI によるコンポーネントライブラリ(?)で、状態に応じた data 属性や、アクセシビリティのための属性を付与してくれます。

本記事の中では「状態」についての定義には触れませんでしたが、不正な状態を作らないためには、何が適正な状態かを明確に意識することも当然ながら重要です。

Zag.js ではさまざまなコンポーネントの状態をステートマシンとして定義しているため、UIコンポーネントにおける状態の参考になるかもしれません。

Zag.js のソースの追い方はまた別途記事にしようと思います。

Footnotes

  1. 2024年の夏前くらい?どこからどこまでが最近なんでしょうね。

  2. この名前もどこが出典なのかはわかりませんが(意味的に自然にたどり着いている可能性も十二分にある)、関数型プログラミング言語であるOCamlにはvariantという概念があり、ここに影響を受けている部分は少なからずあるかもしれません。

  3. とはいいつつ、Vuetifyの2系とかはもともとスタイルごとにbooleanで指定する形式で、Vuetify3系でvariantに変更されたので、ここ数年の話なのかもしれません。

ikuma-t

ikuma-t

about

9割笑顔、1割 (´・ω・)