Ir al contenido

Stack · React 19

React 19, usado como disciplina

Las Actions sustituyen el boilerplate de formularios, useOptimistic sustituye el plugin de optimistic state que dejaste de mantener, los Server Components dejan de ser experimentales. Enviamos React 19 a producción hoy y migramos codebases de React 18 sin romper al equipo.

Por qué este stack

Actions en lugar del boilerplate de formularios

Un formulario ahora es una función, no un custom hook. `useState`, `onSubmit`, `setError`, `setSubmitting`: las tres piezas de estado que escribías en cada formulario colapsan en una sola Action. El compiler gestiona el pending state, el error state, el success state. El formulario vuelve a hacer su trabajo.

useOptimistic en lugar de una librería de state-management para una sola feature

Una actualización optimista significaba una slice Redux, un store Zustand o un reducer custom. Ahora es un hook de una línea. La UI se actualiza al instante, el servidor responde, el hook reconcilia. Sin librería de optimistic state que mantener, sin race conditions.

El hook use() simplifica los datos asíncronos

Leer una promise dentro de un componente significaba un boundary Suspense más un fetcher más un wrapper tipado. El hook `use()` acepta la promise directamente y Suspense gestiona el resto. La capa de datos se encoge; el boundary pasa a ser la parte que de verdad diseñas.

Server Components, ahora estables

El Server Component corre en el servidor, el Client Component corre en el navegador, y la costura entre los dos es una anotación clara a nivel de archivo. El data fetch ocurre donde viven los datos. El bundle se queda pequeño porque la mayoría de los componentes nunca llegan al navegador.

Document metadata, ref-as-prop, asset loading: victorias pequeñas que se suman

Title y meta tags nativos emitidos desde cualquier punto del árbol, la ceremonia de `forwardRef` desaparece, la nueva API de asset loading precarga hojas de estilo y scripts mientras la página se renderiza. Cada una es pequeña; sumadas, recortan cientos de líneas de código.

Qué construimos con esta tecnología

Arquitectura React 19 en producción

Server Components por defecto, Client Components donde vive la interacción, boundaries `"use client"` a nivel de archivo revisados por impacto en el bundle.

Formularios con React 19 Actions

Cada formulario una Action tipada con progressive enhancement, error states surgidos vía useActionState, feedback optimista vía useOptimistic.

Patrones de UI optimista

useOptimistic conectado a Server Actions para feedback instantáneo, rollback en error de servidor, gestión de datos stale en el success.

Streaming vía Suspense

Los fetches largos llegan al navegador mientras se resuelven, el first paint se queda bajo un segundo y la parte lenta se enciende cuando está lista.

Asset loading y preinit

`preload`, `preinit` y la nueva API de asset loading sustituyen la manipulación ad-hoc de `<link>` en el head.

Document metadata nativos

Title, meta y link tags por route emitidos desde el árbol de componentes, sin librería de head manager.

ref como prop (fin de forwardRef)

Los componentes nuevos aceptan `ref` como prop normal, los viejos migran de forma incremental cuando se tocan.

Patrones TypeScript 5+ strict

Tipos de Action, tipos de Server Action, tipos de optimistic update: todos generados desde los mismos schemas Zod que usa el servidor.

Migración desde React 18

Codebase actualizado en sitio, comportamiento conservado, las nuevas APIs adoptadas de forma incremental para que el equipo aprenda tocando código real.

Evaluación del React Compiler

Lo encendemos donde paga (formularios, listas, árboles de memo pesados) y lo dejamos apagado donde añade fricción.

Tuning del concurrent rendering

`useTransition` y `useDeferredValue` colocados donde de verdad importan (typeahead, filtros de listas grandes), no dispersos de forma defensiva.

Error boundaries con las nuevas APIs

La nueva API de error reporting, boundaries recuperables, stack traces solo en dev conectados al pipeline de logging existente.

Un formulario con React 19 Actions, useOptimistic y una Server Action

Un comment composer que se actualiza al instante, sincroniza al database vía Server Action, hace surgir los errores inline y hace rollback del estado optimista si falla. Sin librería de formularios, sin librería de state-management, sin fetch wrapper.

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ó.

Preguntas frecuentes

Cuéntanos qué estás construyendo sobre React 19

Nuevos proyectos y migraciones desde React 18 igual de bienvenidos.