0
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?

Server Component から MUI の Tooltip を直接使うと hydration mismatch になる

0
Last updated at Posted at 2026-08-12

こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。

このサイトには、DB を眺めるためのローカル専用の管理画面があります。Next.js の App Router で、表示は Server Component が中心です。

表のセルに説明を出したくて MUI の Tooltip を使ったところ、hydration mismatch の警告が出ました。

何が起きるか

Tooltip は、子要素にイベントハンドラと ref を付けて動きます。そのため、子要素が「有効な React 要素かどうか」を内部で判定しています。

Server Component から渡した子要素は、ストリーミング SSR の途中では未解決の参照として届くことがあります。すると判定が偽になり、Tooltip はフォールバックとして余分な要素で包みます。

サーバ側では包まれ、クライアント側では包まれません。DOM の構造が食い違うので、hydration mismatch になります。

RSC 境界を越えて渡した子要素はストリーミング SSR 中に未解決 (Lazy) 参照として
届くことがあり、Tooltip 直下に置くと isValidElement 判定が false になって
サーバ側だけ余分な <span> で包まれ、hydration mismatch になる
(Tooltip.js の children フォールバック)。

厄介なのは、条件が揃ったときだけ起きることです。単純なケースでは再現しません。ストリーミングの分割の仕方によって、起きたり起きなかったりします。

対処: 直接の子を client 側で作る

Tooltip の直接の子要素を、client のモジュールの中で生成すれば解決します。

'use client';

/** Tooltip + Box(span)。中身 (children) は RSC から渡ってきてよい。 */
export function TooltipBox({ title, ...boxProps }: { title: ReactNode } & Omit<BoxProps, 'title'>) {
  return (
    
  );
}

Tooltip が見るのは、この Box です。これは client のモジュールで作られているので、判定が安定します。

その Box の中身は、Server Component から渡ってきて構いません。Tooltip は中身までは見ません。

Tooltip の直接の子要素をこの client module 内で生成することで
サーバ/クライアントの構造を一致させる。
Server Component で Tooltip を使うときは必ずこちらを経由すること。

境界を越えるものを、1 段階内側にずらす、という発想です。

型で少しはまる

実装で 1 つ引っかかったのが、title の型でした。

// title は BoxProps 側にも `title?: string`(HTML 属性)があるため、そのまま交差させると
// ReactNode & string に潰れて要素を渡せない。HTML の title 属性は Tooltip が付けるので Omit する。
export function TooltipBox({ title, ...boxProps }: { title: ReactNode } & Omit<BoxProps, 'title'>) {

Tooltip の title は表示内容なので ReactNode を受けたいところです。一方、HTML の要素にも title 属性があり、こちらは文字列です。

両方の型を交差させると、ReactNode & string になって文字列しか渡せなくなります。HTML 側の title を除いてから交差させることで解決しました。

同じ名前で意味の違うプロパティは、ラッパを書くときによく衝突します。どちらを採るかを決めて、もう一方を除く必要があります。

使うほうを強制できない

この対処の弱点は、規約に頼っていることです。Tooltip を直接使っても、たいていの場合は動いてしまいます。

lint のルールで「特定のモジュールからの import を禁止する」ことはできます。ただ、管理画面の一部では Tooltip を直接使ってよい場所(client component の中)もあるので、一律の禁止にはしませんでした。

代わりに、ドキュメントとコメントに書いています。

Server Component で Tooltip を使うときは必ずこちらを経由すること。

規約で守る部分は、破ったときに何が起きるかを一緒に書いておくと、守られやすくなります。「必ず経由すること」だけだと、理由が分からないので例外を作られます。

同種の問題

RSC の境界をまたぐときの問題は、これ以外にもいくつか踏みました。共通しているのは、「子要素を検査するライブラリ」との相性です。

React.Children を使うもの、isValidElement で分岐するもの、cloneElement で props を足すものです。これらは、子要素が具体的な要素であることを前提にしています。

境界を越えた子要素は、その前提を満たさないことがあります。ライブラリ側の実装によるので、事前に判断するのは難しいです。

実務的には、「子要素を検査するタイプのコンポーネントは client 側で包む」を既定にしておくのが安全でした。

まとめ

  • MUI の Tooltip は子要素を検査するので、RSC 境界を越えた子を直接渡すと SSR とクライアントで構造がずれる
  • 対処は、直接の子要素を client のモジュール内で作ること。中身は RSC から渡ってよい
  • 同名で意味の違う props(title)は、片方を Omit してから交差させる
  • 規約で守る部分は、破ったときに何が起きるかを一緒に書く
  • 子要素を検査するライブラリは、client 側で包むのを既定にしておく
0
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
0
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?