JSON Form Handling: React Hook Form, Zod Validation, and Submission
Last updated:
JSON form handling converts HTML form data into structured JSON objects — Object.fromEntries(new FormData(form)) produces a flat JSON object, while React Hook Form's handleSubmit gives you a typed JSON object matching your schema. React Hook Form + Zod integration is straightforward: define z.object({ name: z.string().min(1), email: z.string().email() }), pass it to zodResolver, and get fully typed form data JSON on submit with zero manual type casting. Multi-step forms maintain JSON state between steps — serialize partial form data to localStorage as JSON for persistence across page reloads using JSON.stringify(getValues()) and restore with JSON.parse() on mount.
Next.js Server Actions receive FormData natively — call formData.get('field') then validate with Zod before processing, eliminating the need for a separate API route. File inputs return a FileList that cannot be JSON-serialized directly — small files convert to base64, while large files require multipart/form-data alongside your JSON fields. This guide covers FormData to JSON conversion, React Hook Form with Zod, Server Actions in Next.js, multi-step form JSON state, file field handling, form arrays with useFieldArray, and optimistic form submission with React 19's useOptimistic.
FormData to JSON Conversion
The fastest path from an HTML form to a JSON object is Object.fromEntries(new FormData(formElement)). This works for text inputs, number inputs, selects, and radio buttons. The result is a flat key-value object where every value is a string — you must coerce types manually. The critical limitation: if a form has checkboxes or a <select multiple>, Object.fromEntries only captures the last value for a repeated key. Use formData.getAll('fieldName') for any field that can have multiple values.
// Basic FormData to JSON
const form = document.querySelector('#myForm') as HTMLFormElement
const formData = new FormData(form)
// Flat object — all values are strings
const flat = Object.fromEntries(formData)
// { name: "Alice", age: "30", role: "admin" }
// Coerce types manually
const json = {
name: flat.name as string,
age: Number(flat.age), // "30" → 30
active: flat.active === 'true', // "true" → true
}
// Multi-value fields — checkboxes, multi-selects
const skills = formData.getAll('skills') // ["js", "ts", "python"]
// Complete approach for mixed forms
function formToJson(form: HTMLFormElement) {
const data = new FormData(form)
const result: Record<string, unknown> = {}
// Collect all keys first
const keys = new Set(data.keys())
for (const key of keys) {
const values = data.getAll(key)
// Single value → string; multiple values → array
result[key] = values.length === 1 ? values[0] : values
}
return result
}
// Controlled checkbox: unchecked boxes are absent from FormData
// Add explicit false for missing boolean fields
function formToJsonWithBooleans(
form: HTMLFormElement,
booleanFields: string[]
) {
const json = formToJson(form)
for (const field of booleanFields) {
json[field] = field in json
}
return json
}Number inputs still return strings via FormData — always coerce with Number() or parseInt() and validate the result is not NaN before processing. Unchecked checkboxes are absent from FormData entirely, so you must add explicit false values for boolean fields you expect. For anything beyond a trivial form, React Hook Form with Zod handles all this coercion and validation automatically.
React Hook Form + Zod JSON Schema
React Hook Form with zodResolver is the standard pattern for typed JSON form data in React. Define a Zod schema — it serves as both the runtime validator and the TypeScript type source. Pass the resolver to useForm with a generic parameter. The handleSubmit callback receives a value that is exactly z.infer<typeof schema> — fully typed, already validated, and coerced to the correct types.
// Install: npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define the Zod schema — this is your JSON shape
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
age: z.coerce.number().int().min(18, 'Must be 18 or older'),
role: z.enum(['admin', 'editor', 'viewer']),
newsletter: z.boolean().default(false),
})
// 2. Derive the TypeScript type — no duplication
type FormData = z.infer<typeof schema>
// { name: string; email: string; age: number; role: "admin"|"editor"|"viewer"; newsletter: boolean }
export function UserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { newsletter: false },
})
// 3. handleSubmit delivers a typed, validated JSON object
const onSubmit = async (data: FormData) => {
// data is fully typed — age is number, newsletter is boolean
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) reset()
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Name" />
{errors.name && <p>{errors.name.message}</p>}
<input {...register('email')} type="email" placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('age')} type="number" placeholder="Age" />
{errors.age && <p>{errors.age.message}</p>}
<select {...register('role')}>
<option value="admin">Admin</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<input {...register('newsletter')} type="checkbox" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting…' : 'Submit'}
</button>
</form>
)
}Use z.coerce.number() instead of z.number() for inputs that render as text in HTML — Zod will call Number() on the string value automatically. The errors object mirrors the shape of your schema, giving you field-level error messages without any manual error state. For server-side errors returned after submission, use setError('root', { message: "..." }) to display a top-level form error.
Next.js Server Actions with JSON
Next.js Server Actions let you handle form submission on the server without a separate API route. The action receives native FormData, which you parse and validate with Zod before processing. For client components using React Hook Form, call the server action directly from handleSubmit — convert the typed JSON object back to FormData or POST it as JSON to the action endpoint.
// app/actions/create-user.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
})
export type ActionState = {
success: boolean
errors?: Partial<Record<keyof z.infer<typeof UserSchema>, string[]>>
message?: string
}
export async function createUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Extract fields from FormData
const raw = {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
}
// Validate with Zod — safeParse returns result instead of throwing
const result = UserSchema.safeParse(raw)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// result.data is now typed and validated JSON
const user = result.data
// await db.user.create({ data: user })
redirect('/users')
}
// app/components/user-form.tsx — using useFormState (React 19: useActionState)
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createUser } from '@/app/actions/create-user'
function SubmitButton() {
const { pending } = useFormStatus()
return <button type="submit" disabled={pending}>Save</button>
}
export function ServerActionForm() {
const [state, action] = useFormState(createUser, { success: false })
return (
<form action={action}>
<input name="name" />
{state.errors?.name && <p>{state.errors.name[0]}</p>}
<input name="email" type="email" />
{state.errors?.email && <p>{state.errors.email[0]}</p>}
<select name="role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
</select>
<SubmitButton />
</form>
)
}Server Actions work with standard HTML form submission — no JavaScript required for the basic case, which means they degrade gracefully in low-JS environments. For React Hook Form + Server Actions together, call startTransition(() => action(formData)) from handleSubmit to trigger the server action with client-side validation already applied. Return a JSON-compatible result object (not a thrown error) so useFormState can update the UI without a full page reload.
Multi-Step Form JSON State
Multi-step forms accumulate JSON state across steps. Each step validates and contributes a portion of the final JSON object. The key challenges are: tracking which step is active, merging partial data without overwriting previous steps, and persisting state across page reloads. localStorage with JSON.stringify solves persistence; React Hook Form's getValues() and reset() handle step-to-step data flow.
// Multi-step form with localStorage JSON persistence
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Each step has its own schema
const step1Schema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const step2Schema = z.object({
company: z.string().min(1),
role: z.string().min(1),
})
const step3Schema = z.object({
plan: z.enum(['free', 'pro', 'enterprise']),
billing: z.enum(['monthly', 'annual']),
})
// Combined schema for final submission
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)
type FullFormData = z.infer<typeof fullSchema>
const DRAFT_KEY = 'signup-form-draft'
const STEP_KEY = 'signup-form-step'
export function MultiStepForm() {
const [step, setStep] = useState(() => {
if (typeof window === 'undefined') return 1
return Number(localStorage.getItem(STEP_KEY) ?? '1')
})
const [accumulated, setAccumulated] = useState<Partial<FullFormData>>(() => {
if (typeof window === 'undefined') return {}
try {
return JSON.parse(localStorage.getItem(DRAFT_KEY) ?? '{}')
} catch {
return {}
}
})
const schemas = [step1Schema, step2Schema, step3Schema]
const form = useForm({
resolver: zodResolver(schemas[step - 1]),
defaultValues: accumulated,
})
// Persist step and draft to localStorage on every change
useEffect(() => {
localStorage.setItem(STEP_KEY, String(step))
}, [step])
const handleNext = form.handleSubmit((stepData) => {
const merged = { ...accumulated, ...stepData }
setAccumulated(merged)
// Persist draft as JSON
localStorage.setItem(DRAFT_KEY, JSON.stringify(merged))
setStep((s) => s + 1)
})
const handleBack = () => setStep((s) => Math.max(1, s - 1))
const handleFinalSubmit = form.handleSubmit(async (stepData) => {
const final = { ...accumulated, ...stepData } as FullFormData
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(final),
})
// Clear draft after successful submission
localStorage.removeItem(DRAFT_KEY)
localStorage.removeItem(STEP_KEY)
})
return (
<form onSubmit={step < 3 ? handleNext : handleFinalSubmit}>
{/* Render fields for current step */}
<p>Step {step} of 3</p>
{step > 1 && (
<button type="button" onClick={handleBack}>Back</button>
)}
<button type="submit">
{step < 3 ? 'Next' : 'Submit'}
</button>
</form>
)
}Validate each step before advancing — never merge unvalidated data into the accumulated JSON object. Use safeParse on the accumulated JSON when restoring from localStorage to catch any schema drift between deployments; if validation fails, clear the draft and start fresh rather than showing confusing pre-filled invalid data.
File Fields and JSON Handling
File inputs return a FileList — not a JSON-serializable value. JSON.stringify({ avatar: fileInput.files[0] }) produces {"avatar":{}} — an empty object, silently dropping the file. You must choose between base64 embedding (small files) or multipart/form-data (large files). React Hook Form's register captures FileList in the form state, but you must handle the serialization explicitly before submission.
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
// Zod schema: file validated client-side, not in the JSON schema
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
// FileList is not JSON-serializable — validate separately
avatar: z
.custom<FileList>()
.optional()
.refine(
(files) => !files || files.length === 0 || files[0].size <= 2 * 1024 * 1024,
'Avatar must be under 2 MB'
),
})
type FormData = z.infer<typeof schema>
export function ProfileForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = async (data: FormData) => {
const file = data.avatar?.[0]
if (file && file.size < 10 * 1024) {
// Small file: base64 inline in JSON
const base64 = await fileToBase64(file)
await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.name,
email: data.email,
avatar: { data: base64, type: file.type, name: file.name },
}),
})
} else if (file) {
// Large file: multipart/form-data
const formData = new FormData()
formData.append('name', data.name)
formData.append('email', data.email)
formData.append('avatar', file, file.name)
// No Content-Type header — browser adds boundary
await fetch('/api/profile', { method: 'POST', body: formData })
} else {
// No file: pure JSON
await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: data.name, email: data.email }),
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<input {...register('email')} type="email" />
<input {...register('avatar')} type="file" accept="image/*" />
{errors.avatar && <p>{String(errors.avatar.message)}</p>}
<button type="submit">Save Profile</button>
</form>
)
}
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve((reader.result as string).split(',')[1])
reader.onerror = reject
reader.readAsDataURL(file)
})
}For multipart uploads alongside JSON metadata, see the JSON multipart guide. The rule of thumb: base64 for files under 10 KB where you need a pure JSON API; multipart/form-data for anything larger. Never attempt to store FileList or File objects in localStorage — they are not serializable and will become {} silently.
Form Arrays and Nested JSON
useFieldArray from React Hook Form manages arrays of objects in the form state, producing nested JSON like { items: [{ name: "A", qty: 1 }, { name: "B", qty: 2 }] }. The array is reactive — appending and removing items updates the form state and re-renders only affected fields. Register nested fields using dot-notation paths: register('items.0.name') or the template `items.${index}.name`.
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Nested JSON schema with an array of items
const schema = z.object({
orderId: z.string().uuid(),
items: z
.array(
z.object({
name: z.string().min(1, 'Item name required'),
quantity: z.coerce.number().int().min(1),
price: z.coerce.number().min(0),
})
)
.min(1, 'At least one item required'),
notes: z.string().optional(),
})
type OrderForm = z.infer<typeof schema>
// {
// orderId: string
// items: Array<{ name: string; quantity: number; price: number }>
// notes?: string
// }
export function OrderForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<OrderForm>({
resolver: zodResolver(schema),
defaultValues: {
orderId: crypto.randomUUID(),
items: [{ name: '', quantity: 1, price: 0 }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
})
const onSubmit = async (data: OrderForm) => {
// data.items is a typed array — JSON.stringify works perfectly
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('orderId')} type="hidden" />
{fields.map((field, index) => (
<div key={field.id}>
{/* Use field.id as key — NOT index — to avoid remount issues */}
<input
{...register(`items.${index}.name`)}
placeholder="Item name"
/>
{errors.items?.[index]?.name && (
<p>{errors.items[index].name?.message}</p>
)}
<input
{...register(`items.${index}.quantity`)}
type="number"
min="1"
/>
<input
{...register(`items.${index}.price`)}
type="number"
step="0.01"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', quantity: 1, price: 0 })}
>
Add item
</button>
<textarea {...register('notes')} placeholder="Order notes" />
{errors.items && !Array.isArray(errors.items) && (
<p>{errors.items.message}</p>
)}
<button type="submit">Place Order</button>
</form>
)
}Always use field.id as the React key for mapped field arrays — using index as the key causes inputs to lose focus and display stale values when items are removed. For deeply nested structures (items with sub-items), call useFieldArray multiple times, scoping each call to its nested path. The resulting JSON structure on submit mirrors your Zod schema exactly. See TypeScript JSON types for advanced nested type patterns.
Optimistic Form Submission
Optimistic form submission updates the UI immediately with the expected result before the server responds. If the server returns an error, the UI rolls back to the previous state. React 19's useOptimistic hook handles this automatically. The JSON state snapshot — the data before the mutation — is the rollback target. For React 18, implement the same pattern manually with a try/catch and explicit state restoration.
// React 19: useOptimistic for instant UI feedback
'use client'
import { useOptimistic, useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
type Comment = { id: string; text: string; author: string; pending?: boolean }
const commentSchema = z.object({
text: z.string().min(1).max(500),
})
export function CommentForm({ initialComments }: { initialComments: Comment[] }) {
const [comments, setComments] = useState<Comment[]>(initialComments)
const [isPending, startTransition] = useTransition()
// useOptimistic: applies optimistic update immediately,
// reverts automatically if the transition fails
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state: Comment[], newComment: Comment) => [...state, newComment]
)
const { register, handleSubmit, reset, formState: { errors } } = useForm({
resolver: zodResolver(commentSchema),
})
const onSubmit = handleSubmit((data) => {
// JSON snapshot of the optimistic comment
const optimistic: Comment = {
id: crypto.randomUUID(),
text: data.text,
author: 'You',
pending: true,
}
startTransition(async () => {
// 1. Immediately add the optimistic JSON state
addOptimisticComment(optimistic)
reset()
try {
// 2. POST the JSON to the server
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Failed to post comment')
// 3. Replace optimistic state with server-confirmed JSON
const saved: Comment = await response.json()
setComments((prev) => [...prev, saved])
} catch {
// useOptimistic rolls back automatically when transition fails
// Restore the draft text for the user to retry
reset({ text: data.text })
}
})
})
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li
key={comment.id}
style={{ opacity: comment.pending ? 0.6 : 1 }}
>
<strong>{comment.author}</strong>: {comment.text}
{comment.pending && ' (saving…)'}
</li>
))}
</ul>
<form onSubmit={onSubmit}>
<textarea {...register('text')} placeholder="Add a comment…" />
{errors.text && <p>{errors.text.message}</p>}
<button type="submit" disabled={isPending}>Post</button>
</form>
</div>
)
}
// React 18 equivalent — manual optimistic pattern
function useOptimisticManual<T>(initial: T) {
const [committed, setCommitted] = useState<T>(initial)
const [display, setDisplay] = useState<T>(initial)
async function mutate(
optimisticValue: T,
action: () => Promise<T>
) {
// JSON snapshot before mutation
const snapshot = committed
setDisplay(optimisticValue)
try {
const result = await action()
setCommitted(result)
setDisplay(result)
} catch {
// Rollback to the JSON snapshot
setDisplay(snapshot)
}
}
return [display, mutate] as const
}The JSON state snapshot is the key concept: before any mutation, capture the current state with JSON.stringify if needed for deep clone, or keep a reference to the immutable previous state object. useOptimistic manages this snapshot internally — the optimistic update is applied during the transition and reverted if the transition throws. For complex forms with dependent server state, combine optimistic updates with server-side JSON data validation to ensure rollback data integrity.
Definitions
- FormData
- A browser Web API that captures the current values of all named form controls as key-value pairs. Constructed via
new FormData(formElement)or manually withnew FormData()andappend(). Supports text, file, and blob parts. Cannot directly serialize file inputs to JSON — files must be extracted asFileobjects and handled separately. - zodResolver
- An adapter from
@hookform/resolvers/zodthat connects a Zod schema to React Hook Form's validation pipeline. It runsschema.safeParse()on form values during validation, maps Zod errors to React Hook Form's error structure, and coerces types according to the schema — turning string inputs into numbers or booleans as declared. - Server Action
- A Next.js App Router feature that marks an async function with
'use server', enabling it to run on the server when called from a client component or bound to a form'sactionprop. Server Actions receiveFormDatanatively, support progressive enhancement (work without JavaScript), and can return serializable JSON results to the client. - useFieldArray
- A React Hook Form hook that manages an array of object fields within a form. Provides
fields(the current array),append(),remove(),insert(),move(), andswap()methods. Each field in the array is tracked with a stableidproperty for use as a React key. The array produces nested JSON on submit, e.g.,{ items: [{ name: "A" }, { name: "B" }] }. - Optimistic update
- A UI pattern where the interface reflects the expected result of a mutation immediately — before the server confirms it — using a JSON state snapshot of the pre-mutation state as the rollback target. React 19's
useOptimistichook implements this automatically: the optimistic value is shown during the async transition and reverted if the transition throws an error. - Form schema
- A declarative description of the shape, types, and validation rules for form data — typically expressed as a Zod, Yup, or JSON Schema object. In React Hook Form + Zod, the form schema serves as both the runtime validator and the TypeScript type source via
z.infer<typeof schema>, eliminating duplication between runtime validation and static type definitions. - Multi-step form
- A form split across multiple pages or views, where each step captures and validates a subset of the final JSON object. State accumulates across steps — typically stored in React state and persisted to
localStorageas JSON. The final submission merges all step data into a single JSON object matching the complete schema, which is then validated again server-side before processing.
FAQ
How do I convert form data to JSON in JavaScript?
Use Object.fromEntries(new FormData(formElement)) for a flat JSON object. For fields with multiple values (checkboxes, multi-selects), use formData.getAll('fieldName') which returns an array. Number inputs return strings via FormData — coerce with Number(). Unchecked checkboxes are absent from FormData entirely, so add explicit false values for boolean fields. For complex forms, React Hook Form with Zod handles all coercion automatically.
How do I use React Hook Form with JSON schema validation?
Install react-hook-form, @hookform/resolvers, and zod. Define a Zod schema, then pass it to useForm via zodResolver: useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) }). The handleSubmit callback delivers a fully typed, coerced JSON object — use z.coerce.number() for number inputs to handle the HTML string-to-number conversion automatically.
How do I handle JSON form submission in Next.js?
Use a Server Action marked with 'use server'. Accept a FormData parameter, extract fields with formData.get('fieldName'), and validate with Zod.safeParse() before processing. Attach the action to a form's action prop, or call it directly from React Hook Form's handleSubmit inside a startTransition. Return a serializable result object — not a thrown error — so useFormState can update the UI without a full page reload.
How do I persist form state as JSON?
Call JSON.stringify(getValues()) and write to localStorage.setItem('formDraft', ...) in a useEffect or inside handleSubmit. On component mount, read back with JSON.parse(localStorage.getItem('formDraft') ?? 'null') and call reset(savedData). Validate the restored JSON against your Zod schema before calling reset() — schema changes between deployments can make old draft data incompatible.
How do I handle file uploads with JSON form data?
File inputs return a FileList that cannot be serialized with JSON.stringify. For files under 10 KB, convert to base64 with FileReader.readAsDataURL() and embed the string in your JSON payload. For larger files, build a FormData object manually: append your JSON fields as strings and the file as a File object, then POST without setting Content-Type. See the JSON multipart guide for server-side parsing.
How do I handle form arrays in React Hook Form?
Use useFieldArray({ control, name: "items" }). Render fields with fields.map((field, index) => ...) using field.id as the React key (not index). Register nested inputs with register(`items.${index}.name`). Call append({ name: "", qty: 1 }) to add rows and remove(index) to delete them. On submit, handleSubmit delivers a nested JSON array matching your Zod schema.
What is the difference between FormData and JSON for form submission?
FormData uses multipart/form-data or application/x-www-form-urlencoded encoding and supports file uploads natively. JSON uses application/json and handles nested objects and arrays naturally but cannot carry binary data. Use JSON when your data is structured with types and nesting; use FormData when you have file uploads or need progressive enhancement with standard HTML forms. React Hook Form gives you a typed JSON object on submit — you choose whether to POST it as JSON or convert to FormData.
How do I implement optimistic form submission with JSON?
In React 19, use useOptimistic(currentState, updateFn). Inside a startTransition, call addOptimisticValue(expectedResult) before the async fetch — the UI updates immediately. If the fetch fails, React reverts to the pre-transition JSON state automatically. For React 18, save a JSON snapshot before mutation, apply the optimistic update with setState, and restore the snapshot in the catch block. Always keep the JSON snapshot immutable so rollback is reliable.
Further reading and primary sources
- React Hook Form documentation — Official React Hook Form docs — API reference and examples
- Zod schema validation — Zod TypeScript-first schema validation library
- Next.js Server Actions — Next.js App Router Server Actions documentation
- React useOptimistic — React 19 useOptimistic hook reference on react.dev
Related guides: JSON multipart · JSON data validation · TypeScript JSON types · JSON Schema patterns
Validate your form's JSON output before submitting
Use the Jsonic formatter to inspect and validate the JSON object your form produces before sending it to the server.
Open JSON Formatter