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点。
- server HTMLには
draft-fallbackがある -
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でそこまで確認できれば、本番へ持ち込むときのレビューもかなり具体的になる。