La maggior parte dei walkthrough "React 19 features" ti mostra le API in isolamento. React 19 in produzione assomiglia al form qui sotto: una Action gestisce il submit, useOptimistic aggiorna la UI prima che il server risponda, la Server Action scrive nel database e l'error state emerge inline se qualcosa va storto. Niente form library, niente libreria di optimistic state, niente fetch wrapper.
1. Il componente form
Il form riceve una prop action. Dentro, useActionState contiene la risposta del server, useOptimistic tiene il commento in volo, useFormStatus riporta se il form sta inviando. Tutto nativo, tutto stabile.
// 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
L'action gira solo sul server. Lo schema Zod valida l'input, il client Supabase scrive la riga, la forma della risposta combacia con l'ActionState che il form si aspetta. La revalidation dice a Next.js di rifare il fetch della lista commenti alla prossima navigazione.
// 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. Il Server Component che lo ospita
Il Server Component legge i commenti esistenti (row-level security limita la query ai commenti che l'utente può vedere) e renderizza il CommentComposer client già con i dati.
// 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. Cosa fa collassare questo
Una versione pre-React-19 di questa feature aveva bisogno di: una form library per stato e validazione, un fetcher per la POST, una libreria di optimistic state (o un reducer custom), una boundary Suspense a livello di pagina, uno step manuale di revalidation e una visualizzazione errori separata. Tutti e cinque i problemi stanno ora in tre file, tutti nativi, tutti type-checked, tutti rivisti dalla stessa persona senza coordinamento tra librerie.
Lo "stack React 19" non è una lista di nuove API da rincorrere. È uno stack di dipendenze che spariscono perché il framework finalmente le ha assorbite.