概要
「コンポーネントのpropとして、できれば文字列だけを受け取るようにしたい。けど、現実的にリンクくらいは受け取れないといけない。」
そんな場面が時折あり、どういうアプローチが良いのか悩みがちです。
完璧ではありませんが、実用的な解決策にたどり着いたのでその内容を記事にしました。
対象読者
- TypeScriptでReactの開発をしている方
- コンポーネントの型安全性を重視したい方
- 再利用可能で堅牢なコンポーネント設計に興味がある方
結論
childrenではなく通常のpropとして渡す。
具体的なシチュエーション
できるだけ小さく分かりやすい例として、チェックボックスのコンポーネントを挙げてみます。
input要素としてのチェックボックスがあり、すぐ側にラベルのテキストがあるようなコンポーネントです。
このときラベルのテキストはstring
として扱えたら楽ですが、なんだかんだリンクくらいは入れたくなります。
例えば以下のように、規約の確認系のチェックボックスに文字列とリンクが混ざっているのはよく見るのではないでしょうか。
これを上手く扱いたいというのが今回の記事の主題です。
簡単に作成できるけど壊れやすいコンポーネント
children
を受け取ってlabel
に渡します。
import type { ComponentProps, ReactNode } from "react";
import { useId } from "react";
type Props = {
children: ReactNode;
} & ComponentProps<"input">;
export function Checkbox1({ children, ...props }: Props) {
const id = useId();
return (
<div>
<input type="checkbox" id={id} {...props} />
<label htmlFor={id}>{children}</label>
</div>
);
}
この場合、使用するときはこのようになります。
<Checkbox1>
<a href="/terms">利用規約</a>と
<a href="/privacy-policy">プライバシーポリシー</a>に同意しました
</Checkbox1>
やりたいことは実現できているのですが、一方でこのようなコードも書けてしまいます。
<Checkbox1>
<a href="/terms">利用規約</a>と
<a href="/privacy-policy">プライバシーポリシー</a>に
<span className="foo">同意しました</span>
</Checkbox1>
どんなマークアップでもスタイルでも、好き放題につけられてしまいます。
そんな状態ではいつ壊れてしまうか分かりません。
やりたかったけどできなかったこと
はじめはchildren
の型を縛るだけで解消するのでは?と考えて、次のようなコードを書いてみました。
type Props = {
- children: ReactNode;
+ children: (string | ReactElement<any, "a">)[];
} & ComponentProps<"input">;
// 型以外は同じ
しかしこれは期待通りに動きませんでした。
ReactのJSX要素は実行時にReact.createElement()
の呼び出しになり、その戻り値の型がReactElement<any, any>
として推論されるため、純粋な型レベルでの制限はできないようです。
私が調べた限りの話なので、もしかしたらうまくできる方法もあるのかもしれませんが……。
一旦はできないものとして話を進めます。
実行時エラーになるコンポーネントを作る
型で縛れないなら実行時エラーにさせるか、と思って作りました。
import type { ComponentProps, ReactNode } from "react";
import { Children, isValidElement, useId } from "react";
type Props = {
children: ReactNode;
} & ComponentProps<"input">;
const isValidChild = (child: unknown) => {
return (
typeof child === "string" || (isValidElement(child) && child.type === "a")
);
};
export function Checkbox3({ children, ...props }: Props) {
const id = useId();
// Children.forEachはchildrenが単一要素・配列・nullでも安全に処理できる
Children.forEach(children, (child, index) => {
if (!isValidChild(child)) {
throw new Error(
`Invalid element at index ${index}. Only strings and <a> elements allowed.`
);
}
});
return (
<div>
<input type="checkbox" id={id} {...props} />
<label htmlFor={id}>{children}</label>
</div>
);
}
これで、先ほどの例のようにchildren
にspan
を渡すとエラーになります。
なりはするのですが、型としてのエラーは出ていないのでなんとなく気持ち悪いです。
実現したいことに対して若干の「やり過ぎ」感もあるので、他の方法も考えてみます。
いっそchildrenで渡さない
children
で渡すからややこしくなってしまうのだと諦め、違う方法で渡すことにしました。
import type { ComponentProps } from "react";
import { Fragment, useId } from "react";
// テキストのみの要素
type Text = {
text: string;
};
// リンク要素(a要素の全プロパティを継承)
type Link = {
text: string;
} & ComponentProps<"a">;
// ユニオン型で「文字列またはリンク」を表現
type Contents = Text | Link;
// 型ガード関数:hrefの有無でリンクかどうかを判定
function isLink(item: Contents) {
return "href" in item;
}
interface Props {
contents: Contents[];
}
export function Checkbox4({ contents, ...props }: Props) {
const id = useId();
return (
<div>
<input type="checkbox" id={id} {...props} />
<label htmlFor={id}>
{contents.map((content, index) => {
if (isLink(content)) {
const { text, ...restProps } = content;
return (
// リンクの場合:a要素として描画
<a key={index} {...restProps}>
{text}
</a>
);
}
// リンクでない = テキストの場合:Fragmentで単なるテキストとして描画
return <Fragment key={index}>{content.text}</Fragment>;
})}
</label>
</div>
);
}
使用する場合は次のようになります。
<Checkbox4
contents={[
{ text: "利用規約", href: "/terms" },
{ text: "と" },
{ text: "プライバシーポリシー", href: "/privacy-policy" },
{ text: "に同意しました" },
]}
/>
href
を持っていたらa
要素、そうでなければ通常のテキストとして描画されます。
href
を渡していない状態ではtext
以外の要素を渡すと型エラーとなり、href
を渡した後でもa
要素に存在しない属性を渡すとエラーとなります。
ComponentPropsを使う利点
本筋からは少し逸れますがComponentProps<"a">
を使用することでの利点を説明しておきます。
type Link = {
text: string;
} & ComponentProps<"a">;
-
target
、rel
、onClick
など、a要素の全属性が利用可能(すべてを手動で書かなくて良い) - 補完が機能する
{
text: "リンク",
// 以下のような属性が漏れなく使える
href: "/example",
target: "_blank",
rel: "noreferrer",
onClick: (e) => console.log("clicked")
}
型ガード関数を使う利点
こちらも少し本筋から少し逸れますが、型ガード関数を使用する利点を説明しておきます。
function isLink(item: Contents): {
return "href" in item;
}
-
isLink(content)
がtrue
の場合、TypeScriptは自動的にcontent
をLink
型として扱う - 条件分岐の中で
content.href
やcontent.target
などのプロパティが正しく補完される - 存在しないプロパティにアクセスしようとするとエラーになる
isLink(content) ? (
// 型ガードのおかげで、この分岐内ではcontentはLink型として扱われる
// 今回は使っていないものの、content.hrefなどを個別に取り出したい場合、正しく補完される
<a key={index} {...content}>
{content.text}
</a>
) : (
// こちらではText型として扱われる
<Fragment key={index}>{content.text}</Fragment>
)
// このようなコードを書こうとすると型エラーになる
<Checkbox5
contents={[
{
text: "利用規約",
href: "/terms",
className: "custom-style" // Error: Object literal may only specify known properties, and 'className' does not exist in type 'Contents'.
},
{ text: "に同意しました" },
]}
/>
せっかくなのでa要素へ渡せる属性をより厳しくする
ただ、先ほどまでの状態ではa
要素にclassName
やstyle
を渡せてしまいます。
せっかくなのでそれもできないようにしておきます。
type Link = {
text: string;
- } & ComponentProps<"a">;
+ } & Omit<ComponentProps<"a">, "className" | "style">
これでa
要素の見た目を勝手に上書きすることもできなくなりました。
まとめ
Reactコンポーネントで「文字列とリンクのみ」を受け付ける型安全な実装方法を考えました。
各アプローチの比較
アプローチ | 型安全性 | 実装の簡単さ | 使うときの分かりやすさ |
---|---|---|---|
childrenをそのまま使用 | ❌ | ⭐⭐⭐ | ⭐⭐⭐ |
childrenの型制限(実現不可) | - | - | - |
実行時エラーにする | ❌ | ⭐⭐ | ⭐ |
children以外のpropを使用 | ✅ | ⭐ | ⭐⭐ |
最終的なchildren
以外のpropを使用するアプローチは、実装は少し複雑にはなってしまいましたが、安全性とほどほどの分かりやすさから、トータルでは一番良いと考えています。
このパターンが活用できる場面
- フォームなどのラベル
- 注意書きや説明文
- CMSなどから使う、見た目のスタイルだけを施したテキストコンポーネント
型安全性を保ちながら、必要最小限の柔軟性を持たせることで、メンテナンスしやすさと使い勝手を両立できるのではないでしょうか。