SSR & Hydration
Why forms are SSR-safe
@domphy/form holds all state in JavaScript — it writes nothing to the HTML. A server-rendered form is just static HTML with empty inputs; the form state initializes on the client when createForm() runs. No hydration mismatch is possible.
The SSR challenge is different: how to pre-populate forms with server data and how to surface server validation errors without a full round-trip.
Pattern 1: pre-populate from a loader
The simplest approach with @domphy/app — load the data in the route loader and pass it to the form as defaultValues:
import { type Listener, toState } from "@domphy/core"
import { type AnyRouteMatch } from "@domphy/router"
import { createRoute } from "@domphy/router"
import { createForm } from "@domphy/form/domphy"
import { inputText, label, formGroup, button } from "@domphy/ui"
import { themeSpacing } from "@domphy/theme"
// Reactive state synced from the router (set up once in app bootstrap):
// router.subscribe("onResolved", () => matches.set(router.state.matches))
declare const matches: ReturnType<typeof toState<AnyRouteMatch[]>>
interface Post {
id: number
title: string
body: string
}
// Route loader fetches the post server-side (or client-side, depending on your setup)
const editPostRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/posts/$postId/edit",
loader: ({ params }) => fetchPost(Number(params.postId)),
component: EditPost,
})
function EditPost(l: Listener) {
const matches = routerState.get(l).matches
const post = matches.find(m => m.routeId === editPostRoute.id)?.loaderData as Post
const form = createForm<{ title: string; body: string }>({
defaultValues: { title: post.title, body: post.body },
onSubmit: async ({ value }) => {
await api.patch(`/posts/${post.id}`, value)
},
})
const titleField = form.field<string>("title", {
validators: { onChange: ({ value }) => value ? undefined : "Title required" },
})
const bodyField = form.field<string>("body", {
validators: { onChange: ({ value }) => value ? undefined : "Body required" },
})
return {
form: [
{
fieldset: [
{ label: "Title", $: [label()] },
{
input: null,
type: "text",
$: [inputText()],
value: (l) => titleField.value(l),
onInput: (e) => titleField.handleChange((e.target as HTMLInputElement).value),
onBlur: () => titleField.handleBlur(),
},
{
p: (l) => String(titleField.errors(l)[0] ?? ""),
hidden: (l) => titleField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
},
{
button: (l) => form.isSubmitting(l) ? "Saving…" : "Save",
type: "submit",
$: [button({ color: "primary" })],
disabled: (l) => !form.canSubmit(l),
},
],
onSubmit: (e) => { (e as Event).preventDefault(); form.handleSubmit() },
_onRemove: () => form.destroy(),
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(3),
maxWidth: themeSpacing(100),
},
}
}Because the route re-runs component when the loader data changes (e.g. navigating to a different post), the form always initializes with the current post's values.
Pattern 2: mergeForm() for server validation errors
When you submit a form via a traditional <form action> POST (no JavaScript), the server can validate the data and return error state. mergeForm() merges that server state into the client form so errors are immediately visible on hydration:
import { mergeForm } from "@domphy/form"
import { createForm } from "@domphy/form/domphy"
// Server returns partial FormState alongside the page HTML
// (e.g. as a <script> tag: `window.__FORM_STATE__ = ...`)
declare const serverFormState: {
values: { email: string; name: string }
fieldMeta?: Record<string, { errorMap?: Record<string, unknown> }>
}
const form = createForm<{ email: string; name: string }>({
defaultValues: { email: "", name: "" },
onSubmit: async ({ value }) => submitForm(value),
})
// Merge server state before rendering — sets field values and errors
if (serverFormState) {
mergeForm(form.form, serverFormState)
}
const emailField = form.field<string>("email", {
validators: {
onChange: ({ value }) => value.includes("@") ? undefined : "Invalid email",
},
})After mergeForm(), the form starts with the submitted values filled in and any server-side errors pre-populated — the user sees their mistakes without retyping.
Pattern 3: transform for SSR
The transform option on FormOptions applies a transformation to the initial state — useful for mapping server payloads into form state format:
const form = createForm<{ title: string }>({
defaultValues: { title: "" },
transform: (data) => {
// data.state is the current BaseFormState
// Return a modified state object
return {
state: {
...(data as any).state,
values: serverPayload.values,
},
}
},
onSubmit: ({ value }) => save(value),
})transform runs once during FormApi construction and only affects the initial state. Subsequent updates do not re-run it.
Serializing form state on the server
If your server runs JavaScript (Node.js / Deno), you can pre-run form validation server-side and serialize the state:
// server-side handler (Node.js)
import { FormApi } from "@domphy/form"
async function handlePost(req: Request) {
const body = await req.json()
// Run validation on the server
const serverForm = new FormApi<{ email: string }>({
defaultValues: body as { email: string },
validators: {
onSubmit: ({ value }) =>
value.email.includes("@") ? undefined : "Invalid email",
},
})
serverForm.mount()
await serverForm.validateAllFields("submit")
const state = serverForm.store.state
const isValid = state.isValid
if (isValid) {
await saveToDatabase(state.values)
return redirect("/success")
}
// Return form state as JSON embedded in the HTML response
return renderPage({
formState: {
values: state.values,
fieldMeta: state.fieldMeta,
},
})
}On the client, pick up formState from the page and call mergeForm() before mounting.
Handling loading states
When defaultValues come from an async source, show a skeleton while data loads:
import { createQuery } from "@domphy/query/domphy"
import { skeleton } from "@domphy/ui"
const postQuery = createQuery(queryClient, {
queryKey: () => ["post", postId],
queryFn: () => fetchPost(postId),
})
const EditPageShell = {
div: (l) => {
if (postQuery.isPending(l)) {
return {
div: [
{ div: null, $: [skeleton()], style: { height: themeSpacing(8), marginBottom: themeSpacing(2) } },
{ div: null, $: [skeleton()], style: { height: themeSpacing(24) } },
],
}
}
if (postQuery.isError(l)) {
return { p: "Failed to load post." }
}
return EditForm(postQuery.data(l)!)
},
}
function EditForm(post: Post) {
// Form is created fresh once data is available — correct defaults guaranteed
const form = createForm<{ title: string; body: string }>({
defaultValues: { title: post.title, body: post.body },
onSubmit: async ({ value }) => api.patch(`/posts/${post.id}`, value),
})
// ... fields and UI
}Creating the form inside the conditional guard ensures it initializes with real data, never with empty defaults.
_onRemove cleanup is required
Always destroy the form when the component unmounts. In SSR setups, component trees can be created and discarded rapidly — leaving forms mounted leaks devtools subscriptions:
{
form: [ /* ... */ ],
_onRemove: () => form.destroy(),
}