Most "React 19 features" walkthroughs show you the API in isolation. Production React 19 looks like the form below: an Action handles the submit, useOptimistic updates the UI before the server responds, the Server Action writes to the database, and the error state surfaces inline if anything goes wrong. No form library, no optimistic-state library, no fetch wrapper.
1. The form component
The form receives an action prop. Inside, useActionState carries the server response, useOptimistic holds the in-flight comment, useFormStatus reports whether the form is submitting. All native, all stable.
// app/[lang]/posts/[id]/CommentComposer.tsx
'use client'
import { useActionState, useOptimistic } from 'react'
import { useFormStatus } from 'react-dom'
import { addComment, type ActionState } from './actions'
interface Comment {
id: string
body: string
authorName: string
createdAt: string
}
const INITIAL_STATE: ActionState = { error: null, comment: null }
export function CommentComposer({
postId,
initialComments,
}: {
postId: string
initialComments: Comment[]
}) {
const [state, formAction] = useActionState(addComment, INITIAL_STATE)
const [optimisticComments, addOptimistic] = useOptimistic(
initialComments,
(current, draft: Comment) => [draft, ...current],
)
async function clientAction(formData: FormData) {
const body = formData.get('body')?.toString() ?? ''
addOptimistic({
id: 'pending',
body,
authorName: 'You',
createdAt: new Date().toISOString(),
})
await formAction(formData)
}
return (
<section>
<form action={clientAction}>
<input type="hidden" name="postId" value={postId} />
<textarea name="body" required minLength={2} />
<SubmitButton />
{state.error ? (
<p role="alert" className="ds-text-error">
{state.error}
</p>
) : null}
</form>
<ul>
{optimisticComments.map((c) => (
<li key={c.id}>
<strong>{c.authorName}</strong>
<p>{c.body}</p>
</li>
))}
</ul>
</section>
)
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Posting…' : 'Post comment'}
</button>
)
}
2. The Server Action
The action runs only on the server. The Zod schema validates the input, the Supabase client writes the row, the response shape matches the ActionState the form expects. Revalidation tells Next.js to refetch the comment list on the next navigation.
// app/[lang]/posts/[id]/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { createServerClient } from '@/lib/supabase/server'
const Input = z.object({
postId: z.string().uuid(),
body: z.string().min(2).max(2000),
})
export interface ActionState {
error: string | null
comment: {
id: string
body: string
authorName: string
createdAt: string
} | null
}
export async function addComment(
_previousState: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = Input.safeParse({
postId: formData.get('postId'),
body: formData.get('body'),
})
if (!parsed.success) {
return { error: 'Please write at least two characters.', comment: null }
}
const supabase = await createServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Please sign in to comment.', comment: null }
const { data, error } = await supabase
.from('comments')
.insert({
post_id: parsed.data.postId,
author_id: user.id,
body: parsed.data.body,
})
.select('id, body, created_at, profiles(display_name)')
.single()
if (error) return { error: 'Could not post the comment.', comment: null }
revalidatePath(`/posts/${parsed.data.postId}`)
return {
error: null,
comment: {
id: data.id,
body: data.body,
authorName: data.profiles?.display_name ?? 'Anonymous',
createdAt: data.created_at,
},
}
}
3. The Server Component that hosts it
The Server Component reads the existing comments (row-level security scopes the query to comments the user is allowed to see) and renders the client CommentComposer with the data already in place.
// app/[lang]/posts/[id]/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import { CommentComposer } from './CommentComposer'
interface Props {
params: Promise<{ id: string }>
}
export default async function PostPage({ params }: Props) {
const { id } = await params
const supabase = await createServerClient()
const { data: comments } = await supabase
.from('comments')
.select('id, body, created_at, profiles(display_name)')
.eq('post_id', id)
.order('created_at', { ascending: false })
const initial = (comments ?? []).map((c) => ({
id: c.id,
body: c.body,
authorName: c.profiles?.display_name ?? 'Anonymous',
createdAt: c.created_at,
}))
return <CommentComposer postId={id} initialComments={initial} />
}
4. What this collapses
A pre-React-19 version of this feature needed: a form library for state and validation, a fetcher for the POST request, an optimistic state library (or a custom reducer), a Suspense boundary at the page level, a manual revalidation step, and a separate error display. All five concerns now sit in three files, all native, all type-checked, all reviewed by the same person without coordination across libraries.
The "React 19 stack" is not a list of new APIs to chase. It is a stack of dependencies that disappear because the framework finally absorbed them.