Closed11

next/linkがaタグを自動的に差し込む問題にちゃんと対処する

kobokobo

Next.js v13から<Link>コンポーネントは自動的にaタグを挿入する形に変更された

<Link> is a React component that extends the HTML <a> element to provide prefetching and client-side navigation between routes. It is the primary way to navigate between routes in Next.js.

単純に見れば、Linkコンポーネントがaタグの変わりをしてくれると思えば良いんだけど、困るケースが2つ

  1. 既にaタグを含むライブラリを使用している場合
  2. ボタンコンポーネントをNext.jsに依存しないパッケージとしてパブリッシュしたい場合

1のケースは明確で、MUIやChakra UI等の外部ライブラリを用いている場合、自分ではどうしようもなくaタグは付いてくるという話

2のケースも最近だとモノレポ構成でNext.jsに依存しないようにコンポーネントを作りたいというニーズがある

今回はこの2点に関して、2023年11月時点のソリューションについて調査

kobokobo

Next.jsのドキュメント

https://nextjs.org/docs/pages/api-reference/components/link#if-the-child-is-a-custom-component-that-wraps-an-a-tag

If the child of Link is a custom component that wraps an <a> tag, you must add passHref to Link. This is necessary if you’re using libraries like styled-components. Without this, the <a> tag will not have the href attribute, which hurts your site's accessibility and might affect SEO. If you're using ESLint, there is a built-in rule next/link-passhref to ensure correct usage of passHref.

子コンポーネントがaタグを含む場合はpassHrefを使ってねと。

ただ、サンプルコードを見ると

import Link from 'next/link'
import styled from 'styled-components'
 
// This creates a custom component that wraps an <a> tag
const RedLink = styled.a`
  color: red;
`
 
function NavLink({ href, name }) {
  return (
    <Link href={href} passHref legacyBehavior>
      <RedLink>{name}</RedLink>
    </Link>
  )
}
 
export default NavLink

となっており、legacyBehaviorオプションが付与されている。
これは、Next.js v12時点と同じ動作をするためのオプションであり、未来永劫にあるオプションではないため、適切ではなさそう

kobokobo

ちなみに、ざっとissueを漁ってみましたが、この件に関して公式な回答はなさそうでした

kobokobo

MUIやChakra UI等のメジャーなライブラリのソリューション

共に同じようなソリューションを提供してくれていて

https://mui.com/material-ui/guides/routing/#link
https://chakra-ui.com/docs/components/link#usage-with-nextjs

Chakra UIのほうが説明が丁寧だったので、そちらを参考にしますが、

import NextLink from 'next/link'
import { Link } from '@chakra-ui/react'

<Link as={NextLink} href='/home'>
  Home
</Link>

のように、ライブラリ側がaspropsで<Link>コンポーネントを受け取るという形式

kobokobo
import { ComponentPropsWithoutRef, ElementType, PropsWithChildren } from 'react'

export type ElementAs = ElementType

export type AsProp<T extends ElementAs> = {
  elementAs?: T
}

export type PolymorphicPropsWithoutRef<
  T extends ElementAs,
  P extends object
> = PropsWithChildren<P> & ComponentPropsWithoutRef<T> & AsProp<T>


type Props<T extends ElementAs> = PolymorphicPropsWithoutRef<
  T,
  { isExternal?: boolean }
>

export const Link = <T extends ElementAs = 'a'>({
  elementAs,
  children,
  ...attr
}: Props<T>) => {
  const Componet = elementAs || 'a'
  return <Componet {...attr}>{children}</Componet>
}
kobokobo

こんな感じでできる

ポイントは、ここで定義するprops(上でいうとelementAsやisExternal)は名前被りが発生してはいけない
例えば、next/linkだとasというプロパティが存在しているので、elementAsの部分をasみたいにしてしまうと被りが発生しビルドエラーになる(ビルドエラーで教えてくれるようになってるだけマシだけど、、)
これは親側が気にしないといけないことなので、ちょっと微妙なポイントかも
ちなみに、Chakra UIとかだと、定義したプロパティに関しては、子のものが適応されるように、親側のpropsからOmitするという手法を取ってそう(だけど、これはむしろエラーが消えてしまい、親側を使う人がプロパティがうまく渡せていないことに気づかないと問題になるかもしれないので、やめておいた方が良いかなと思っている

kobokobo

Radix UIのソリューション

Slotという解決策を取っている

https://www.radix-ui.com/primitives/docs/utilities/slot

これは子コンポーネントに対してpropsを転送するみたいな形を取る

なので、Linkコンポーネントに対してスタイルを転送するみたいなことが可能なはず

kobokobo

こんな感じ

import { Slot } from '@radix-ui/react-slot'
import { ComponentPropsWithoutRef } from 'react'
import { twMerge } from 'tailwind-merge'

type Props = {
  asChild?: boolean
} & ComponentPropsWithoutRef<'a'>

export const AnchorButton = (props: Props) => {
  const { asChild = false, children, className, ...restProps } = props
  const Component = asChild ? Slot : 'a'
  return (
    <Component
      {...restProps}
      className={twMerge(className, 'bg-white border border-cyan-700 p-4 w-24')}
    >
      {children}
    </Component>
  )
}

本当はasChildがついている場合はComponentPropsWithoutRef<typeof children>それ以外の場合はComponentPropsWithoutRef<'a'>みたいな雰囲気なんだろうけど、まぁ、asChildで使う場合って、子要素にnext/linkみたいにaをラップしたコンポートになっている予定だと思うので、これでもよいような気がする

kobokobo

これで明らかに変なpropsを渡されるみたいなことはなさそう?かな?

kobokobo

まとめ

MUIやChakra UIのようなasプロパティによるソリューション(Polymorphic Component)に関して、一定量、コンポーネントの難易度は上がるものの、ある程度TypeScriptやReactに対しての成熟度があるチームであればメンテナンスできるような気がした

一方、Radix UIのようなasChildプロパティによるソリューションに関して、たしかにPolymophic Componentより難易度は下がる一方、結果、Radix UIへの依存が出てくるので、今回の本来の目的であったnext/linkへの依存排除と同等レベル(もしくは将来性がわからないという点ではそれ以上かも)のものが出来上がってしまうので、個人的には微妙なのかなと思ってしまった(元々Radix UIに依存したコンポーネント群を作ってるならありだと思う)

なので、個人的にはPolymophic Componentの方に一票。
だけど、もうnext/linkに依存させちゃったほうがメンテナンスし易いよねというのは全然あると思うので、その時は共通パッケージ側にintegration-nextとかextends-nextとかそういうディレクトリを切って入れてしまうというのもありなんじゃないかなと思う

このスクラップは2023/11/16にクローズされました