Stack · React 19

React 19, usado como disciplina y no como persecución

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

01

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.

02

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.

03

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.

04

Server Components, estables, no experimentales

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.

05

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

¿React 19 es lo bastante estable para producción?

Sí. Hemos enviado a producción React 19 RC y 19 stable en varias apps desde el release. Los caveats conocidos (librerías de terceros con peer deps desfasadas, edge cases de Server Components) están documentados en nuestro playbook interno y los manejamos en el código.

¿Conviene actualizar desde React 18 ahora?

Depende del codebase. La mayoría de apps React 18 se benefician de la actualización porque las nuevas APIs sustituyen un stack de dependencias; la migración dura uno o tres días en un codebase medio. Te decimos directo si la actualización aún no merece el trabajo.

¿El React Compiler está listo?

Lo bastante listo para muchos formularios, listas y árboles de memo pesados; no listo para cualquier codebase. Hacemos benchmark con y sin compiler en tu aplicación antes de enviar; las ganancias son concretas en algunos sitios y cero en otros.

¿Cuándo usáis Server Components frente a Client Components?

Server por defecto. Client solo cuando el componente lee APIs solo de navegador (useState, useEffect, window, intersection observer, etcétera) o interactúa directamente con input. Cada boundary `'use client'` se revisa por impacto en el bundle.

¿useOptimistic frente a Zustand o Redux para optimistic updates?

useOptimistic para el caso simple (un solo formulario, una sola mutación de lista), Zustand o Redux solo cuando el estado vive de verdad en muchos componentes y necesita persistencia más allá de una única interacción de usuario. La mayoría de los proyectos que tocamos pierden la librería de state tras la actualización a React 19.

¿TypeScript strict con React 19?

Sí, cada tipo se estrecha desde el input de la Action, pasando por la firma de la Server Action, hasta la fila de la base de datos. Usamos Zod en el boundary para que el check en runtime y el tipo en compile-time tengan la misma forma.

¿Cómo manejáis la migración desde librerías que se quedan atrás respecto a React 19?

O fijamos la librería y convivimos con los warnings de peer-dep React 18 (casi siempre inofensivos), o cambiamos la librería por una alternativa mantenida. El plan de migración nombra cada librería que se queda atrás para que el coste quede visible desde el principio.

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

Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Nuevos proyectos y migraciones desde React 18 igual de bienvenidos.