La mayoría de los walkthroughs de "React 19 features" te muestran la API en aislamiento. React 19 en producción se parece al formulario de abajo: una Action gestiona el submit, useOptimistic actualiza la UI antes de que el servidor responda, la Server Action escribe en la base de datos, y el error state surge inline si algo falla. Sin librería de formularios, sin librería de optimistic state, sin fetch wrapper.
1. El componente formulario
El formulario recibe una prop action. Dentro, useActionState lleva la respuesta del servidor, useOptimistic mantiene el comentario en vuelo, useFormStatus reporta si el formulario está enviando. Todo nativo, todo estable.
// 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. La Server Action
La action corre solo en el servidor. El schema Zod valida el input, el client de Supabase escribe la fila, la forma de la respuesta coincide con el ActionState que el formulario espera. La revalidation le dice a Next.js que vuelva a hacer fetch de la lista de comentarios en la siguiente navegación.
// 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. El Server Component que lo aloja
El Server Component lee los comentarios existentes (row-level security acota la query a los comentarios que el usuario puede ver) y renderiza el CommentComposer client con los datos ya cargados.
// 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. Qué hace colapsar esto
Una versión pre-React-19 de esta feature necesitaba: una librería de formularios para estado y validación, un fetcher para el POST, una librería de optimistic state (o un reducer custom), un boundary Suspense a nivel de página, un paso manual de revalidation y una visualización de errores aparte. Las cinco preocupaciones caben ahora en tres archivos, todas nativas, todas type-checked, todas revisadas por la misma persona sin coordinación entre librerías.
El "stack React 19" no es una lista de APIs nuevas que perseguir. Es un stack de dependencias que desaparecen porque el framework finalmente las absorbió.