🕹️

リンクとしても使えるボタンコンポーネントの作り方(React)

2023/09/05に公開

as プロパティに任意の要素を渡すことで、コンポーネントをその要素で描画することができる仕組みを用意しました。仕組みの実態はいい感じの型定義です。

<Button onClick={handleClick}>ボタン</Button>

<Button as="a" href="/foo/bar">リンクボタン</Button>

Button コンポーネントは普段は button 要素で描画されますが、 as="a" を渡した場合は a 要素で描画されます。また、コンポーネントの props の型定義がその要素に最適化され、 href を渡すことができるようになります。

要素以外にコンポーネントも渡すことができ、 next/link 等の独自の振る舞いが加えられたコンポーネントを使うこともできます。props も next/link に最適化されます。

import Link from "next/link";

<Button as={Link} href="/foo/bar">
  リンクボタン
</Button>;

サンプルプロジェクト

as で拡張した様子を Storybook で確認できるようにしたプロジェクトを作っておきました。参考にしてみてください。

https://github.com/aku11i/playground/tree/main/2023-09-button-component-usable-as-link

実装差分: https://github.com/aku11i/playground/pull/2

実装解説

さも自分が考えた方法であるかのように書いてしまいましたが、 as で描画する要素を変更するのは React ではよく知られているテクニックであり、またコンポーネントを渡す実装もChakra UIで採用されています。
Chakra UI を採用したプロダクトで 2 年ほど開発していたので as prop にはとてもお世話になっていました。

今回は Chakra UI が内部で使用する型定義をほぼそのまま使用させてもらいました。
これは system.types.tsx を取り出して多少の調整を行ったものです。
コードを改変して使用しているため、念のために元プロジェクトのライセンスを埋め込んでいます。

ライセンス部分
/*
MIT License

Copyright (c) 2019 Segun Adebayo

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import { ComponentProps } from "react";

export type As<Props = any> = React.ElementType<Props>;

export type OmitCommonProps<
  Target,
  OmitAdditionalProps extends keyof any = never
> = Omit<Target, "as" | OmitAdditionalProps>;

export type RightJoinProps<
  SourceProps extends object = object,
  OverrideProps extends object = object
> = OmitCommonProps<SourceProps, keyof OverrideProps> & OverrideProps;

type MergeWithAs<
  ComponentProps extends object,
  AsProps extends object,
  AdditionalProps extends object = object,
  AsComponent extends As = As
> = RightJoinProps<ComponentProps, AdditionalProps> &
  RightJoinProps<AsProps, AdditionalProps> & {
    as?: AsComponent;
  };

export type PropsWithAs<
  Component extends As,
  Props extends object
> = RightJoinProps<ComponentProps<Component>, Props> & {
  as?: As;
};

export type PropsOf<T extends As> = React.ComponentPropsWithoutRef<T> & {
  as?: As;
};

export type ComponentWithAs<
  Component extends As,
  Props extends object = object
> = {
  <AsComponent extends As = Component>(
    props: MergeWithAs<
      React.ComponentProps<Component>,
      React.ComponentProps<AsComponent>,
      Props,
      AsComponent
    >
  ): JSX.Element;
  displayName?: string;
  propTypes?: React.WeakValidationMap<any>;
  contextTypes?: React.ValidationMap<any>;
  defaultProps?: Partial<any>;
  id?: string;
};

ComponentWithAs<Component, Props> の型を使って定義したコンポーネントには as prop が生え、指定された場合にはコンポーネントが求める props その要素のものに切り替わるようになっています。

例えばボタンコンポーネントを作る場合、このように使います。使い方は冒頭で説明した通りです。

Button.tsx
export type ButtonProps = {
  variant?: "primary" | "secondary";
};

export const Button: ComponentWithAs<"button", ButtonProps> = ({
  variant = "primary",
  as: Component = "button",
  ...props
}) => {
  // 本来はここで variant に沿ったスタイルの適用を行うが、今は省略

  return <Component {...props} />;
};

UI 表現としてのリンク

ちょうど良いタイミングで「Button と Link、どう実装する?」や「リンクは UI 表現か?」を読んで、今までリンクについて考える時に「UI 表現」と「振る舞い」の 2 つを混ぜてしまっていたことが分かりました。

実際、今回のプロジェクトの UI デザイナーは、text という大枠の中で bodytitlecaption と同列に link を定義しており、私は初め違和感を覚えましたが、テキストの見た目としてのリンクの定義であると理解できました。

このように as で振る舞いを、 variant で見た目を変更できるテキストコンポーネントを作れば、「UI 表現としてのリンク」と「振る舞いとしてのリンク」をそれぞれコードに反映できると考えました。

<Text as="a" variant="link" href="/foo/bar">
  テキストリンク
</Text>

<Text as={Link} variant="link" href="/foo/bar">
  テキストリンク
</Text>

pros/cons

今回の実装のポイントをまとめます。

👍 拡張性が高い

as を渡すことでボタンをリンクボタンにできますし、テキストコンポーネントやカードコンポーネントをよりアクセシビリティに優れた要素で描画することもできます。

<Text as="time" datetime="2018-07-07">
  July 7
</Text>

<Card as="article"></Card>

👍 独自の振る舞いを持ったコンポーネントをそのまま利用できる

next/link のように、独自の機能を持っていてそれ自体が HTML 要素になっているコンポーネントを UI コンポーネントライブラリと組み合わせて使うのは意外と難しいです。
next/link と UI コンポーネントライブラリのどちらの Link コンポーネントも <a /> として描画されるため、ネストさせるような記述を行うと a 要素が重複してしまいます。 [1]

今回の方法だとルーターライブラリが提供する Link コンポーネントを as={Link} と渡すだけで使うことができます。

👎 スタイルがズレないよう注意する

要素毎にデフォルトのスタイルが異なることがあるので注意する必要があります。
例えば a 要素は display: inline で button 要素は display: inline-block であるため、ページに配置した時に見え方が異なってしまう可能性があります。
これは Button コンポーネントで常に display: inline-block で上書きをするようにしておいたり、CSS リセットを使ったりすることで要素間のスタイルのズレを抑えておくことで解消できます。

また、ページのデザインが意図せず変わってしまったことを検知できるように Visual Regression Testing を導入するとより安全です。

👎 予想外の使われ方をする可能性がある

ComponentWithAs は自由度が高く、 <Button as="img" /> といった使われ方をする可能性があります。
しかし Button コンポーネントに button や a 以外の要素に対応できる配慮がされていないと、カーソルの見た目が変わらなかったりアクセシビリティに配慮されていない実装になってしまいます。
これを防ぐには、Button コンポーネントの実装を改修するのも一つですが、型定義に調整を加えて as に渡すことのできる要素やコンポーネントを制限することである程度は対処できそうです。(コンポーネントを制限する型定義を作るのは大変そう…)

応用: next/image にも使えるかも?

今回はボタンとリンクに絞って説明しましたが、同じ方法で img 要素を描画するコンポーネントで next/image を使うこともできそうですね。

import NextImage from "next/image";

<Image as={NextImage} src="" />;

最後に

今回 UI コンポーネントを整備しているプロジェクトでは Tailwind を用いており、基礎的な UI コンポーネントを自前で作っています。そこに Chakra UI の良いところを取り入れることができて大変満足しています。

AlphaDrive では toB SaaS や Web メディアの開発・運用を行っており、一緒にフロントエンドを開発してくれる方を募集しています。
カジュアル面談もやっています。みなさまのご応募をお待ちしております!

https://www.wantedly.com/projects/891500

脚注
  1. next/link ではlegacyBehavior prop を指定することでネストを一応回避できます。Next.js v12 までのデフォルト挙動でした。legacy と名が付いている通り、今後はあまり推奨しない機能であると思います。 ↩︎

GitHubで編集を提案

Discussion