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?

React 19.3は新機能より境界が本命。browser()とViewTransitionを3ケースで試す

0
Last updated at Posted at 2026-09-11

AI coding agentにReactの画面を作らせると、window is not definedへの対処としてmounted flagを足し、画面切り替えには<ViewTransition>を被せてくることがある。ビルドが通ると、そのまま採用したくなる。

でも、そこで確認できたのは「コードが通った」ことだけだ。

  • server HTMLに何が出たか
  • hydrate後にどのUIへ切り替わったか
  • どのstate更新がView Transitionを起動したか
  • Suspense fallbackからcontentへの変化をどう見せたいか

このあたりは、別々に見たほうがいい。

React 19.3で<ViewTransition>がstableになり、react-domにはbrowser()が入った。自分なら本番画面へ直接入れず、1 routeだけのfixtureを作る。確認するのは3ケースだ。

server render -> browser-only render
urgent update -> Transition update
Suspense fallback -> final content

新APIの紹介というより、曖昧だった境界をcomponent treeへ書き直す作業として試してみる。

まず検証場所と観測点を固定する

検証branchでReactのversionを19.3.0へ固定する。

pnpm add react@19.3.0 react-dom@19.3.0
mkdir -p artifacts/react-19-3-boundary

route名や配置はframeworkに合わせればいい。ここでは次の名前で進める。

route: /react-19-3-boundary
server artifact: artifacts/react-19-3-boundary/server.html

browser checks:
  - draft-fallback
  - saved-draft-editor
  - preview-panel

本番routeへは混ぜず、確認が終わったら丸ごと消せるfixtureにしておく。新APIを試すbranchで変更範囲まで広げると、問題がReact側なのか既存実装側なのか分からなくなる。

もう一つ、成功ログを先に作らない。期待値と観測結果は分けて残す。

Case 1: browser()でserverとbrowserを分ける

browserはreact-domからimportし、use(browser())として使う。server render中はcomponentの処理を止め、最寄りの<Suspense> fallbackをHTMLへ残す。browserではundefinedを返し、そのままrenderが続く。

localStorageを読む最小のcomponentなら、こう書ける。

"use client"

import { Suspense, use, useState } from "react"
import { browser } from "react-dom"

function SavedDraft() {
  use(browser("draft is stored in localStorage"))

  const [draft, setDraft] = useState(
    () => localStorage.getItem("draft") ?? ""
  )

  return (
    <textarea
      data-testid="saved-draft-editor"
      value={draft}
      onChange={(event) => {
        const next = event.target.value
        setDraft(next)
        localStorage.setItem("draft", next)
      }}
    />
  )
}

export function BrowserOnlyFixture() {
  return (
    <Suspense
      fallback={<p data-testid="draft-fallback">Loading draft...</p>}
    >
      <SavedDraft />
    </Suspense>
  )
}

RSCを使う構成では、use(browser())を呼ぶのはClient Componentだ。"use client"をどこに置くかはframeworkのmodule境界に合わせる。

ここでbrowser()をpage全体へ置くのは雑すぎる。localStorageが必要なのはSavedDraftだけなので、browser-onlyにするsubtreeもそこまで小さくする。serverで出せる見出しや説明文までfallbackへ落とす理由はない。

server HTMLをファイルで見る

画面を目視する前に、serverから返ったHTMLを保存する。

curl -s http://localhost:3000/react-19-3-boundary \
  > artifacts/react-19-3-boundary/server.html

# server HTMLにはfallbackがある
rg 'draft-fallback' artifacts/react-19-3-boundary/server.html

# browser-onlyなtextareaはまだない
if rg -q 'saved-draft-editor' artifacts/react-19-3-boundary/server.html; then
  echo 'unexpected browser-only markup in server HTML' >&2
  exit 1
fi

期待するのは次の2点。

  1. server HTMLにはdraft-fallbackがある
  2. saved-draft-editorはない

browser()はhydration問題の万能修正ではない。browser-only APIが必要な場所を明示し、server側には何を返すか決めるための境界だ、と読むほうが実装を崩しにくい。

hydrate後は「editorが出た」だけで終わらせない

Playwrightが入っているなら、hydrate後のUIも別のassertionにする。

await page.goto("/react-19-3-boundary")

await expect(page.getByTestId("saved-draft-editor")).toBeVisible()
await expect(page.getByTestId("draft-fallback")).toBeHidden()

test runnerがなければ手動確認でもいい。ただし、server HTMLの確認とbrowser表示の確認を一つのpassへ丸めないほうがいい。

consoleのhydration warningも観測対象にする。まだ確認していないならpassではなくnot-observedと書く。ここを曖昧にすると、UIが見えたというだけでhydrationまで正常だったことになる。

Case 2: 即時更新とTransition更新を別操作にする

<ViewTransition>を置いただけでは、すべてのstate更新がanimation対象になるわけではない。通常の即時更新では起動せず、startTransitionで包んだ更新などが対象になる。

同じUIに2つのbuttonを置くと、差がかなり見やすい。

import { startTransition, useState, ViewTransition } from "react"

function PreviewFixture() {
  const [mode, setMode] = useState<"compact" | "wide">("compact")
  const next = mode === "compact" ? "wide" : "compact"

  return (
    <>
      <button type="button" onClick={() => setMode(next)}>
        urgent update
      </button>

      <button
        type="button"
        onClick={() => {
          startTransition(() => setMode(next))
        }}
      >
        transition update
      </button>

      <ViewTransition>
        <section data-testid="preview-panel" data-mode={mode}>
          {mode}
        </section>
      </ViewTransition>
    </>
  )
}

観測欄は次の3つに分ける。

  • data-modeは切り替わったか
  • 更新経路はsetStateかstartTransitionか
  • View Transitionを観測できたか

state更新の成否とanimationの有無を別の列にする。animationが見えなかったとき、render自体の失敗なのか、urgent updateだったのか、観測環境の問題なのかを混ぜないためだ。

Case 3: Suspenseの配置でupdateとenter / exitを分ける

最後はfallbackからcontentへの切り替えだ。

<ViewTransition>をSuspenseの外へ置くと、fallbackからcontentまで同じinstanceの変化として扱われ、updateになる。

<ViewTransition>
  <Suspense fallback={<PreviewSkeleton />}>
    <Preview />
  </Suspense>
</ViewTransition>

fallbackとcontentを別々の<ViewTransition>で包むと、片方のexitともう片方のenterとして扱われる。

<Suspense
  fallback={
    <ViewTransition>
      <PreviewSkeleton />
    </ViewTransition>
  }
>
  <ViewTransition>
    <Preview />
  </ViewTransition>
</Suspense>

配置は、欲しい見え方から決める。skeletonが完成版previewと同じ箱の読み込み途中なら、updateとしてつなぐほうが自然だ。一方で、placeholderが退場して別のpanelが登場する見せ方なら、exit / enterを分けたほうがcomponent treeの意図に合う。

2パターンを同時に本番へ入れず、fixtureのvariantとして切り替える。named shared transitionやrouter連携、凝ったCSSは後回しでいい。まずfallbackとcontentを同一要素として扱うのか、別要素として扱うのかを決める。

結果表は「未確認」を消さない

fixtureを回したら、環境情報と結果を1枚にする。

- browser: (名前とversion)
- framework: (名前とversion)
- react: 19.3.0

| case | server HTML | hydrated UI | update path | observed transition | result |
|---|---|---|---|---|---|
| browser-only | fallback present / absent | visible / missing | hydrate | n/a | pass / fail |
| urgent update | n/a | changed / unchanged | setState | yes / no / not-observed | pass / fail / not-run |
| transition update | n/a | changed / unchanged | startTransition | yes / no / not-observed | pass / fail / not-run |
| Suspense outer VT | fallback | final content | reveal | update / other / not-observed | pass / fail / not-run |
| separate VT | fallback | final content | reveal | exit+enter / other / not-observed | pass / fail / not-run |

not-runとnot-observedを残せるようにしておくと、agentも人間も結果を都合よくpassへ変換しにくい。このfixtureで欲しいのは成功率ではなく、どの境界を実際に通ったかという証拠だ。

なお、<ViewTransition>の対象は現時点ではDOMだ。React Nativeや全browserで同じように動く、とこの結果から広げてはいけない。browser名とversionを表の上へ書くのはそのためでもある。

AI coding agentにはAPI名ではなく完了条件を渡す

agentへの指示をReact 19.3を使っていい感じにanimateしてで終えると、たぶん見た目は作ってくれる。ただ、更新経路とfallbackの意味は未定義のままだ。

自分なら、次の条件をそのまま渡す。

- localStorageを読むsubtreeだけをbrowser-onlyにする
- server HTMLにはdraft-fallbackを残す
- hydrate後にsaved-draft-editorを表示する
- urgent updateとTransition updateを別操作にする
- Suspense / ViewTransition配置の意図をtest名へ残す
- 未実行のcaseを完了扱いしない

React 19.3の新APIを採用するかどうかは、その後でいい。

browser()と<ViewTransition>は、コードを短くする魔法ではない。server出力、hydrate後のUI、更新経路を分けて書けるのが便利なのだ。小さなfixtureでそこまで確認できれば、本番へ持ち込むときのレビューもかなり具体的になる。

参考

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?