🐡

"use client" は Server Component と Client Component の境界につけよう

2023/08/16に公開
3

🌼 はじめに

最近 Next.js の app Router で開発してて、不思議なワーニングに出会いました。

Props must be serializable for components in the "use client" entry file, "handleClick" is invalid.

これがなんなのかがすごく気になり、"use client"について色々調べたので共有したいと思います。

1. "use client" は何なのか

"use client"は Server Component と Client Component の境界の宣言です。公式ドキュメントにもそう書いてました。

The "use client" directive is a convention to declare a boundary between a Server and Client Component module graph.

https://nextjs.org/docs/getting-started/react-essentials#the-use-client-directive

ここで注目すべきところは、"use client"を宣言したらそのコンポーネントだけではなく、インポートしてるすべても Client Component としてみなされるということです。

Once "use client" is defined in a file, all other modules imported into it, including child components, are considered part of the client bundle.

つまり"use client"は「このコンポーネントは Client Component だよ」という意味ではなく、「このコンポーネントと子どもたちは Client Component だよ」という意味になります。

2. Client Component 同士の props 渡しに Warning が?

では最初に紹介したワーニングをもう一度見てみましょう。

Props must be serializable for components in the "use client" entry file, "handleClick" is invalid.

"use client"がついてるコンポーネントに渡される Props は、必ず serializable でないといけないという意味ですね。

Server Component から Client Component に props を渡すとき、シリアライズ可能である必要があります。とういうことで、Server Component が関数や日付などの値を直接 Client Component に渡すことはできないです。

その理由については以下の記事で詳しく説明してくれてるのでご参考ください。
https://zenn.dev/uhyo/articles/react-server-components-multi-stage

でも問題は、Client Component から Client Component に props を渡す場合もこのワーニングが出るということです。ざっくりコードで説明すると以下のような状況です。

"use client"

import { useState } from "react";
import { Child } from "@/components/Child";

export const Parents = () => {
    const [count, setCount] = useState(0)
    const handleClick = () => {
        setCount(prev => prev + 1)
    }

    return <Child handleClick={handleClick} />
}
"use client"

type ChildProps = {
    handleClick: () => void
}

// Props must be serializable for components in the "use client" entry file, "handleClick" is invalid.
export const Child = ({ handleClick }: ChildProps) => {
    return <button onClick={handleClick}>ボタン</button>
}

Server Component から Client Component に関数を渡せないのはわかったけど、なぜ Client Component 同士のやり取りにもこのワーニングが出るのかがすごく謎だったので理由を考えてみました。

先ほど"use client"は境界宣言だと説明しました。これは、実際がどうかは置いといて意味合い的に"use client"付きコンポーネントの親は Server Componentということになります。

だから"use client"ついてるのに、 serializable ではない props 受け取ってるところは全部このワーニングを出ると思います。

解決方法は、子コンポーネントから"use client"を削除することです。

- "use client"

type ChildProps = {
    handleClick: () => void
}

export const Child = ({ handleClick }: ChildProps) => {
    return <button onClick={handleClick}>ボタン</button>
}

"use client"ない場合、Server Component からも Client Component からも呼び出せるコンポーネントになります。

でも上記のChildの場合、親コンポーネントは必ず Client Component のはずです。なぜなら先程話した通り、Server Component から Client Component に関数を流すことはできないからです。親コンポーネントが Client Component だと明確なので、ワーニングも消えたのではないかと思いました。

今まで話した諸々を踏まえたら、すべての Client Component に"use client"をつけるのではなく、Server Component と Client Component 境界となるファイルに一回だけつけるのが良いでしょう。

公式ドキュメントも一度だけつけることをおすすめしてます。

"use client" does not need to be defined in every file. The Client module boundary only needs to be defined once, at the "entry point", for all modules imported into it to be considered a Client Component.

3. 汎用性の高いコンポーネントの場合

"use client"を境界の宣言として使ったほうがいいというのがわかりました。

でも難しいのは、atomic design的に言うと atom ぐらいの粒度のコンポーネントです。こういう子たちは色んなところで使われるはずなので、"use client"つけたほうがいいかつけないほうがいいか混乱してました。

その場合どうすればいいかを、いくつかのパターンを自分なりに考えてまとめてみました。

3-1. 親が Client Component で、自分も Client Component

"use client"つけなくていいでしょう

type ButtonProps = {
    handleClick: () => void
}

export const Button = ({ handleClick }: ButtonProps) => {
    const { text } = useButton()
    return <button onClick={handleClick}>{ text }</button>
}

props で関数も受け取ってるしフックも使ってるので、このこは絶対に Client Component コンポーネントです。そして親コンポーネントも必ず Client Component のはずです。(propsで関数受け取ってるので)

この場合は親が"use client"つけてるはずなので、Buttonには"use client"はつけなくて良いと思います。

3-2. 親がどうなるか分からないが、自分は Client Component

"use client"つけましょう

type TextProps = {
    text: string
}

export const Text = ({ text }: TextProps) => {
    const { color } = useText()
    return <p style={{ color }}>{ text }</p>
}

これは Server Component でも Client Component でもインポートできるコンポーネントです。Client Component からインポートした場合は親が"use client"つけてるはずなので特に問題はないでしょう。

問題は Server Component からインポートした場合です。その場合、親コンポーネントにもTextコンポーネントにも"use client"がないのにフックを使ってるので、ルール違反になります。

この場合は親がどのコンポーネントになるか明確ではないので、Textコンポーネントに"use client"をつけといたほうがいいと思います。

もしTextコンポーネントに"use client"つけて、その親のどこかでも"use client"がついてる状況だとしても問題はないと思います。Client Component から Client Component を呼び出すのはルール違反ではないし、Props must be serializableのワーニングもあくまでワーニングで、エラーではないからです。

3-4. 親がどうなるか分からないが、自分は Server Component

"use client"つけなくていいでしょう

type TextProps = {
    text: string
}

export const Text = ({ text }: TextProps) => {
    return <p>{ text }</p>
}

この場合、親が Server Component だとTextも Server Component になるし、Client Component だとTextも Client Component になるので"use client"なしでいいでしょう。勝手に親に合わせられるはずです。

4. まとめ

🌷 終わり

色々考えたり整理したりしましたが、やはりまだリリースして間もないので本当のベストプラクティスが何なのかはあまりわかりませんね。今回の記事はかなり主観的なものなので、もっといい意見ある方はぜひ教えていただきたいです。

GitHubで編集を提案

Discussion

koichikkoichik

Server Component が関数や日付などの値を直接 Client Component に渡すことはできない

実際はDateオブジェクトを渡すことはできます
その他SetMap、意外なところではPromiseを渡すこともできます (サーバ側でsettledになるとクライアント側もsettledになります)
おそらく以下に定義されているものがServer ComponentsからClient Componentsに渡すことができるSerializableな値の一覧かと思われます
https://github.com/facebook/react/blob/98f3f14d2e06fb785103296318204cd154d5d0ed/packages/react-server/src/ReactFlightServer.js#L128-L154

みんちゃんみんちゃん

コメントありがとうございます!本当に渡せますね😳
Next.jsの公式ドキュメントでDateは渡せないと書いてありましたので、てっきりそうだと思いました。

Props passed from the Server to Client Components need to be serializable. This means that values such as functions, Dates, etc, cannot be passed directly to Client Components.

なぜ渡せるのかが気になって調べてみたら、ReactがresolveModelToJSON関数でシリアライズしてるように見えました。

https://github.com/facebook/react/blob/98f3f14d2e06fb785103296318204cd154d5d0ed/packages/react-server/src/ReactFlightServer.js#L988-L993
https://github.com/facebook/react/blob/98f3f14d2e06fb785103296318204cd154d5d0ed/packages/react-server/src/ReactFlightServer.js#L1096-L1098

手元で Server Component から Client Component に Date や Map を props に渡してみてもエラーにならなかったし正常に動いてたので、おっしゃる通りだと思います。勉強になりました!