1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

"buttonタグ"と"next/linkのLinkコンポーネント"を切り替えるButtonコンポーネントを実装する

Posted at

はじめに

nextjs × typescript を利用したとあるプロジェクトでは、HTML 標準の button タグを使って共通の Button コンポーネントを実装していました。
しかし、プロジェクトを進めていく中で以下のような課題が上がりました。

  • button タグでは、実装者によっては nextjs のプリフェッチを活かせない
  • 別タブ遷移のために window オブジェクトを利用する必要がある

上記課題を解決させるため、button タグだけでなく next/link のLinkコンポーネントでもレンダリングできるように共通の Button コンポーネントを修正しました。
結構複雑だったのでその備忘録を残したいと思います。

【Tips】プリフェッチについて

next/link を使って画面遷移させると、デフォルトでプリフェッチ機能が働きます。
とりあえず公式から説明をコピペします(Google翻訳済)。

プリフェッチは、 <Link /> コンポーネントがユーザーのビューポートに入ったときに (最初に、またはスクロールを通じて) 行われます。 Next.js は、リンクされたルート (href で示される) とそのデータをバックグラウンドでプリフェッチして読み込み、クライアント側のナビゲーションのパフォーマンスを向上させます。 プリフェッチは運用環境でのみ有効です。

簡単にいえば、プリフェッチとは『ブラウザに該当のリンクが表示されたタイミングで、裏でそのリング先のページの取得を行う』ことを指します。
ユーザのロード待ち時間を減らすので、とても強力な機能です。

しかし、next/link 以外の方法で画面遷移を行う場合(router.pushなど)は、実装者は意図的にプリフェッチの処理を記述しなければなりません(参考記事)。

このことは単純に実装量が増えたり、プリフェッチの実装自体を忘れる可能性があるといったデメリットにつながります。
そのため、画面遷移処理はなるべくbuttonタグのonClickではなく、next/linkによるものにした方が賢明でしょう。

【Tips】button タグの別タブ遷移について

button タブで画面遷移させる場合、onClick で next/router を利用することが多いと思います。
しかし、next/router は別タブ遷移をさせるオプションはなく、代わりに window オブジェクトを利用する必要があります(古いですがnextjsのissuesを参照)。

window オブジェクトはブラウザ側で取得するオブジェクトのため、クライアントサイドのレンダリングでしか利用することが出来ません。
nextjs の恩恵を受けるためにも、windowオブジェクトはなるべく使わずに実装したいところです。

結論のソースコード

以下のように実装すると実現できます。

Buttonコンポーネント
import Link from 'next/link';
import { ComponentPropsWithoutRef, Ref, forwardRef } from 'react';
// cssファイル(説明は割愛)
import styles from './index.module.scss';

// buttonタグのprops + ref
type ButtonProps = ComponentPropsWithoutRef<'button'> & { ref?: Ref<HTMLButtonElement> };
// next/link のprops + disabled
type CustomLinkProps = ComponentPropsWithoutRef<typeof Link> & {
  disabled?: boolean;
};
// Buttonコンポーネントが受け取ることができる値
type AS = 'button' | 'Link';
// Buttonコンポーネントが設定できるprops
type Props<T extends AS> = T extends 'button' ? ButtonProps : CustomLinkProps;

export const Button = forwardRef<HTMLButtonElement, Props<AS>>((props, ref) => {
  // propsにhrefがあればLinkコンポーネントとしてレンダリング
  if ('href' in props) {
    const { disabled, ...linkAttributes } = props as unknown as CustomLinkProps;
    return (
      <Link className={`${styles.commonDesign} ${disabled && styles.linkDisabled}`} {...linkAttributes}>
        {linkAttributes.children}
      </Link>
    );
  }

  // buttonタグとしてレンダリング
  const buttonAttributes = props as ButtonProps;
  return (
    <button ref={ref} className={styles.commonDesign} {...buttonAttributes}>
      {buttonAttributes.children}
    </button>
  );
}) as <T extends 'button' | 'Link' = 'button'>(p: Props<T>) => JSX.Element;

作成したButtonコンポーネントの呼び出し方は以下です。

使い方
// デフォルトの場合、'button'タグとしてレンダリング
<Button>申し込む</Button>

// 'Link'設定の場合、Next/Linkコンポーネントとしてレンダリング
<Button<'Link'> href=''>詳細</Button>

// 'button'設定の場合、'button'タグとしてレンダリング
<Button<'button'>>詳細</Button>

解説

注目ポイントは二つです。

Conditional Types による型定義

型定義
// buttonタグのprops + ref
type ButtonProps = ComponentPropsWithoutRef<'button'> & { ref?: Ref<HTMLButtonElement> };
// next/link のprops + disabled
type CustomLinkProps = ComponentPropsWithoutRef<typeof Link> & {
  disabled?: boolean;
};
// Buttonコンポーネントが受け取ることができる値
type AS = 'button' | 'Link';
// Buttonコンポーネントが設定できるprops
type Props<T extends AS> = T extends 'button' ? ButtonProps : CustomLinkProps;

PropsConditional Types を利用して型定義しています。
これを利用することで、ジェネリクスで定義されている T が 'button' のときは ButtonProps のみを、'Link' のときは CustomLinkProps のみを受け取るようになります。

「'button' を設定しているときは、hrefを定義できないようにする」といったケースのために今回この実装を採用しました。Typescriptを利用している以上、型安全な実装ができることが一番です。

Buttonコンポーネントの型をキャスト

型のキャスト
export const Button = forwardRef<HTMLButtonElement, Props<AS>>((props, ref) => {
  // ・・・省略・・・
}) as <T extends 'button' | 'Link' = 'button'>(p: Props<T>) => JSX.Element;

Buttonコンポーネントがrefを受け取れるようにしつつ、ジェネリクスを扱えるようにする必要がありました。その際、一筋縄では行かなかったのでこちらの記事を参考にさせていただきました。

結論、Buttonコンポーネントの型をキャストさせることでこの問題を解決させました。
最後の行で、定義した型 AS を使わず 'button' | 'Link'としているのは、Buttonコンポーネントを呼び出す際にジェネリクスがどのような値を受けつけるのかを明示的にするためです。

この方法以外で検討したこと

今回の実装の他にも、Button の props に as を追加し、as で 'button' か 'Link' のどちらかを設定する案も検討していました。

ジェネリクスを利用した方法よりもわかりやすいといったメリットがあったのですが、この案では as を指定しなかった場合に型の制限ができない(例:asを指定しなかった場合はbuttonタグでレンダリングするのにhrefを受け取れてしまう)といった課題があり断念しました。

このケースの実装例もいちおう載せておきます。
改善できるつよつよエンジニアの方はぜひご一報ください><

propsで'button'と'Link'を切り替える実装
import Link from 'next/link';
import React, { ComponentPropsWithoutRef, forwardRef } from 'react';
// cssファイル(説明は割愛)
import styles from './index.module.scss';

// buttonタグのprops + α
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { as?: 'button' };
// next/link の Linkコンポーネントの props + α
type CustomLinkProps = ComponentPropsWithoutRef<typeof Link> & { as: 'Link'; disabled?: boolean };
// Buttonコンポーネントが設定できるprops
type Props = ButtonProps | CustomLinkProps;

export const Button = forwardRef<HTMLButtonElement, Props>((props, ref) => {
  // as に 'Link' が設定されていたらLinkコンポーネントとしてレンダリング
  if (props.as === 'Link') {
    const { disabled, children, ...linkAttributes } = props as CustomLinkProps;
    return (
      <Link className={`${styles.commonDesign} ${disabled && styles.linkDisabled}`} {...linkAttributes}>
        {children}
      </Link>
    );
  }

  // buttonタグとしてレンダリング
  const { children, ...buttonAttributes } = props as ButtonProps;
  return (
    <button ref={ref} className={styles.commonDesign} {...buttonAttributes}>
      {children}
    </button>
  );
});

この実装では判別可能なユニオン型 (discriminated union)を活用しています。
一応この場合も Buttonコンポーネント呼び出し時に as を記述すれば、as の値に合わせて props の制限をかけることができます。

おわりに

チーム開発してたら今回みたいな課題に触れる機会があるのでたまらないですね。

以上、最後まで見ていただいてありがとうございました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?