StackPractices
advanced Por Mathias Paulenko

Guía Completa de las Características de React 19

Un recorrido práctico por React 19: componentes de servidor, el hook use(), actions, ref como prop, metadatos del documento, carga de assets y el React Compiler.

Introducción

React 19 trajo dos grupos de cambios bien diferenciados. El primero es un conjunto de primitivas nuevas (use(), actions, useActionState, useOptimistic, useFormStatus y ref como prop normal) que cambian la forma de obtener datos y manejar formularios. El segundo es de infraestructura: los componentes de servidor pasaron de experimentales a estables, <title> y <meta> pueden renderizarse desde cualquier componente, los assets se precargan automáticamente y el React Compiler memoiza el código en tiempo de build.

Esta guía recorre cada característica con ejemplos de TypeScript ejecutables, explica cuándo aporta de verdad y termina con los cambios incompatibles que vas a encontrar en la migración. Asume que ya manejas los hooks de React 18 y tienes un proyecto con React 19 — la mayoría de los ejemplos funcionan en Next.js 15 o en un setup de Vite + React 19.

Componentes de Servidor

Los componentes de servidor se ejecutan exclusivamente en el servidor y envían el resultado renderizado al cliente. Como nunca llegan al navegador, pueden consultar bases de datos, leer archivos y usar credenciales privadas sin exponer nada — y suman cero kilobytes al bundle del cliente.

// app/users/page.tsx — Componente de servidor
import { db } from "@/lib/db";

async function getUsers() {
  const users = await db.user.findMany({
    take: 20,
    orderBy: { createdAt: "desc" },
  });
  return users;
}

export default async function UsersPage() {
  const users = await getUsers();
  
  return (
    <main>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name} — {user.email}
          </li>
        ))}
      </ul>
    </main>
  );
}

La frontera entre servidor y cliente la dibuja la directiva "use client". Todo lo que queda por debajo es código de cliente; todo lo que queda por encima permanece en el servidor. Se combinan pasando props serializables desde un componente de servidor hacia uno de cliente — un patrón habitual es obtener los datos a nivel de página y entregárselos a un widget interactivo.

// Combinando componentes de servidor y de cliente
// app/dashboard/page.tsx — Componente de servidor
import { db } from "@/lib/db";
import { DashboardChart } from "./DashboardChart";

export default async function Dashboard() {
  const stats = await db.getStats();
  
  return (
    <main>
      <h1>Dashboard</h1>
      {/* Pasar datos serializables a componentes de cliente */}
      <DashboardChart data={stats} />
    </main>
  );
}

// app/dashboard/DashboardChart.tsx — Componente de cliente
"use client";

import { useState } from "react";

interface DashboardChartProps {
  data: { label: string; value: number }[];
}

export function DashboardChart({ data }: DashboardChartProps) {
  const [hovered, setHovered] = useState<number | null>(null);
  
  return (
    <div className="chart">
      {data.map((item, i) => (
        <div
          key={i}
          className="bar"
          style={{ height: `${item.value}%` }}
          onMouseEnter={() => setHovered(i)}
          onMouseLeave={() => setHovered(null)}
        >
          {hovered === i && <span className="tooltip">{item.value}</span>}
          <span className="label">{item.label}</span>
        </div>
      ))}
    </div>
  );
}

Reglas de los componentes de servidor

Los componentes de servidor PUEDEN:
  - Acceder a bases de datos, sistemas de archivos y APIs internas
  - Importar y usar librerías exclusivas de servidor
  - Pasar props serializables a componentes de cliente
  - Usar async/await directamente

Los componentes de servidor NO PUEDEN:
  - Usar useState, useEffect, useReducer ni otros hooks de estado
  - Usar manejadores de eventos (onClick, onChange, etc.)
  - Usar APIs del navegador (window, document, localStorage)
  - Importar librerías exclusivas de cliente

Los componentes de cliente NO PUEDEN:
  - Acceder a bases de datos o sistemas de archivos
  - Usar librerías exclusivas de servidor
  - Importar componentes de servidor directamente
  - Usar async/await en el render (usar use() en su lugar)

Cómo pasar datos entre ambos:
  - Servidor → Cliente: props serializables (strings, números, arrays, objetos)
  - Cliente → Servidor: server actions o rutas de API
  - Nunca pasar funciones, instancias de clases ni datos no serializables

La regla de “props serializables” es la que más problemas genera: no puedes pasar manejadores de eventos, instancias de clases, Dates, Maps ni Sets a través de la frontera. Si necesitas que el cliente dispare trabajo en el servidor, expone una server action en lugar de intentar pasar un callback. Para profundizar en los patrones de arquitectura alrededor de esta frontera, consulta la guía de React Server Components.

El hook use()

use() desenvuelve promesas y lee contexto. A diferencia de cualquier otro hook, puede llamarse de forma condicional: dentro de bloques if, bucles o después de un return anticipado. Esa única propiedad lo convierte en el reemplazo de toda una categoría de boilerplate de useEffect + useState para obtener datos.

import { use } from "react";

// Desenvolviendo una promesa
async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
}

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// El padre crea la promesa
function App({ userId }: { userId: string }) {
  const userPromise = getUser(userId);
  
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Hay dos detalles que importan. Primero, la promesa se crea en el padre. Llamar a getUser() dentro de UserProfile durante el render lanzaría una petición nueva en cada render. Segundo, use() se integra con Suspense: el componente suspende hasta que la promesa se resuelve y el <Suspense> más cercano muestra el fallback. También conviene envolver el árbol en un error boundary para capturar rechazos, o tendrás un error sin manejar en lugar de una vía de reintento.

El segundo caso de uso es leer contexto de forma condicional, algo que useContext nunca pudo hacer:

// use() con contexto — puede llamarse condicionalmente
import { createContext, use, useContext } from "react";

const ThemeContext = createContext<"light" | "dark">("light");

function ThemedButton({ showTheme }: { showTheme: boolean }) {
  // use() puede llamarse condicionalmente — useContext no
  if (showTheme) {
    const theme = use(ThemeContext);
    return <button className={`btn btn-${theme}`}>Click me ({theme})</button>;
  }
  return <button className="btn">Click me</button>;
}

¿Cuándo no usar use()? Si la obtención de datos debe ocurrir en el cliente en cada montaje (un feed en vivo, una suscripción con polling), un efecto con cleanup sigue siendo la herramienta correcta. use() es para “renderizar una vez cuando esta promesa se resuelva”, no para suscripciones.

Actions y form actions

Las actions son funciones que se pasan al atributo action de un formulario. Reemplazan el clásico ritual de onSubmit + e.preventDefault() + fetch(), y habilitan progressive enhancement: el formulario se envía con un POST normal aunque el JavaScript todavía no haya cargado.

// Server action
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

async function createUser(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  
  if (!name || !email) {
    return { error: "Name and email are required" };
  }
  
  await db.user.create({
    data: { name, email },
  });
  
  revalidatePath("/users");
  return { success: true };
}

// Formulario que usa la action
function CreateUserForm() {
  return (
    <form action={createUser}>
      <input name="name" type="text" required placeholder="Name" />
      <input name="email" type="email" required placeholder="Email" />
      <button type="submit">Create User</button>
    </form>
  );
}

Una server action sigue siendo un endpoint HTTP por dentro, así que trata su FormData como entrada no confiable: valida y autoriza dentro de la action, nunca en el componente de cliente. El beneficio del “progressive enhancement” es real pero fácil de exagerar — en una SPA puramente de cliente (sin framework), las actions se comportan como funciones async normales al enviar; el POST sin JS solo funciona cuando un framework como Next.js lo conecta.

useActionState

useActionState conecta una action con el estado del componente: devuelve el último resultado de la action, una versión envuelta que pasas al formulario y un flag de pendiente, así dejas de construir a mano los estados isSubmitting y error.

import { useActionState } from "react";

interface State {
  error?: string;
  success?: boolean;
}

async function submitForm(prevState: State, formData: FormData): Promise<State> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  
  if (!name || name.length < 2) {
    return { error: "Name must be at least 2 characters" };
  }
  
  if (!email || !email.includes("@")) {
    return { error: "Valid email is required" };
  }
  
  try {
    const res = await fetch("/api/users", {
      method: "POST",
      body: formData,
    });
    
    if (!res.ok) {
      return { error: "Failed to create user" };
    }
    
    return { success: true };
  } catch {
    return { error: "Network error" };
  }
}

function UserForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    submitForm,
    {}
  );
  
  return (
    <form action={formAction}>
      <input name="name" type="text" placeholder="Name" />
      <input name="email" type="email" placeholder="Email" />
      
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create User"}
      </button>
      
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">User created!</p>}
    </form>
  );
}

Fíjate en la firma: tu action recibe (prevState, formData); el resultado anterior va primero, no el formData. Este hook se llamaba useFormState en las versiones experimentales; si encuentras ese nombre en código viejo o posts antiguos, es la misma API.

useOptimistic

useOptimistic permite que la UI refleje una mutación al instante, antes de que el servidor la confirme. Le pasas el estado actual y un reducer que describe cómo debe verse un elemento pendiente; React revierte el valor optimista automáticamente cuando la action termina.

import { useOptimistic } from "react";

interface Message {
  id: string;
  text: string;
  sent: boolean;
}

function MessageList({ messages, sendMessage }: {
  messages: Message[];
  sendMessage: (text: string) => Promise<void>;
}) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newMessage: Message) => [
      ...state,
      { ...newMessage, sent: false },
    ]
  );
  
  async function handleSubmit(formData: FormData) {
    const text = formData.get("message") as string;
    addOptimisticMessage({ id: crypto.randomUUID(), text, sent: false });
    await sendMessage(text);
  }
  
  return (
    <div>
      <ul>
        {optimisticMessages.map((msg) => (
          <li key={msg.id} className={msg.sent ? "sent" : "pending"}>
            {msg.text}
            {!msg.sent && <span className="spinner" />}
          </li>
        ))}
      </ul>
      
      <form action={handleSubmit}>
        <input name="message" type="text" required placeholder="Type a message..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

El comportamiento de rollback es lo que hace que este hook valga la pena: si sendMessage lanza un error, el mensaje pendiente desaparece solo, sin limpieza manual. El detalle es que la reconciliación solo funciona si el servidor vuelve a renderizar el componente con los datos reales (en Next.js, vía revalidatePath o el retorno de una server action). Si la prop messages del padre nunca se actualiza, el elemento optimista se esfuma al resolver la action y el usuario lo ve parpadear. Este patrón combina naturalmente con el patrón de actualización optimista que se usa en todo el stack.

flowchart diagram: Usuario envía el formulario

useFormStatus

useFormStatus da a un componente hijo acceso al estado de envío del formulario que lo contiene: flag de pendiente, datos enviados, método y action. La limitación importante: solo funciona en componentes renderizados dentro del elemento <form>, no en el componente que posee el formulario.

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending, data, method, action } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? (
        <>
          <span className="spinner" /> Submitting...
        </>
      ) : (
        "Submit"
      )}
    </button>
  );
}

// Uso — SubmitButton es hijo del formulario
function ContactForm() {
  return (
    <form action={submitContact}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <SubmitButton />
    </form>
  );
}

Existe sobre todo para que los botones de envío reutilizables puedan deshabilitarse sin prop drilling. Se importa desde react-dom, no desde react; importarlo desde react es un error habitual de migración.

ref como prop

En React 19, ref es una prop normal en los componentes de función. forwardRef ya no hace falta y está en camino de deprecación.

// React 18 — requería forwardRef
// const Input = React.forwardRef<HTMLInputElement, InputProps>(
//   (props, ref) => <input ref={ref} {...props} />
// );

// React 19 — ref es simplemente una prop
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  ref?: React.Ref<HTMLInputElement>;
}

function Input({ label, ref, ...props }: InputProps) {
  return (
    <label>
      {label && <span className="label">{label}</span>}
      <input ref={ref} {...props} />
    </label>
  );
}

// Uso
function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  function focusInput() {
    inputRef.current?.focus();
  }
  
  return (
    <>
      <Input ref={inputRef} label="Name" type="text" />
      <button onClick={focusInput}>Focus input</button>
    </>
  );
}

forwardRef sigue funcionando (el código existente no se rompe), pero los componentes nuevos deberían recibir ref como prop y declararla en la interfaz de props, como en el ejemplo.

Metadatos del documento

React 19 eleva al <head> del documento las etiquetas <title>, <meta> y <link> renderizadas dentro de los componentes. Eso significa que un componente hoja puede declarar sus propias etiquetas SEO sin una librería dedicada como react-helmet.

function BlogPost({ post }: { post: { title: string; description: string } }) {
  return (
    <article>
      {/* Metadatos del documento — se elevan al <head> */}
      <title>{post.title}</title>
      <meta name="description" content={post.description} />
      <meta property="og:title" content={post.title} />
      <meta property="og:description" content={post.description} />
      <link rel="canonical" href={`https://stackpractices.com/blog/${post.slug}`} />
      
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

La elevación deduplica etiquetas por clave, así que un hijo puede sobrescribir el <meta name="description"> del padre; gana la etiqueta renderizada más profunda. Las etiquetas que genera el framework (como los exports metadata de Next.js) conviven sin problema; React solo gestiona las que renderizó él mismo.

Carga de assets

React 19 se integra con las APIs de precarga de recursos de la plataforma: un <link rel="preload"> renderizado en un componente se eleva al head, y el render puede suspenderse mientras se resuelven las fuentes y las hojas de estilo — así el navegador no pinta contenido sin estilos.

// React 19 suspende mientras cargan los assets
import { Suspense } from "react";

function ProfilePhoto({ src }: { src: string }) {
  return (
    <img src={src} alt="Profile" loading="lazy" />
  );
}

// precargar assets
function App() {
  return (
    <>
      {/* Precargar fuentes e imágenes */}
      <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" />
      <link rel="preload" href="/images/hero.webp" as="image" />
      
      <Suspense fallback={<div className="skeleton" />}>
        <ProfilePhoto src="/api/avatar/123" />
      </Suspense>
    </>
  );
}

Para control programático también existen las APIs preload, preinit y prefetchDNS exportadas desde react-dom, útiles cuando el asset no se conoce hasta el runtime:

import { preload, preinit } from "react-dom";

preload("/fonts/inter.woff2", { as: "font", type: "font/woff2" });
preinit("/styles/editor.css", { as: "style" });

Esto importa sobre todo para el Largest Contentful Paint: declarar la imagen hero y la fuente principal como preload en el componente que las necesita mantiene la pista junto al uso en lugar de enterrada en una plantilla global. Para la parte de métricas, la guía de Web Performance y Core Web Vitals cubre cómo interactúa la precarga con el LCP.

React Compiler

El React Compiler analiza los componentes en tiempo de build y memoiza valores y funciones automáticamente, haciendo innecesaria la mayoría de useMemo y useCallback escritos a mano.

// Antes — memoización manual
function ProductList({ products }: { products: Product[] }) {
  const sorted = useMemo(
    () => products.sort((a, b) => a.price - b.price),
    [products]
  );
  
  const handleClick = useCallback((id: string) => {
    addToCart(id);
  }, []);
  
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id} onClick={() => handleClick(p.id)}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}

// Después — el React Compiler se encarga de la memoización
function ProductList({ products }: { products: Product[] }) {
  const sorted = products.sort((a, b) => a.price - b.price);
  
  function handleClick(id: string) {
    addToCart(id);
  }
  
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id} onClick={() => handleClick(p.id)}>
          {p.name} — ${p.price}
        </li>
      ))}
    </ul>
  );
}

El Compiler no es un flag de tsconfig; es un plugin de Babel (o una integración de framework) que instalas y habilitas en tu pipeline de build:

# Instalar el plugin del compilador y su compañero de ESLint
npm install -D babel-plugin-react-compiler eslint-plugin-react-compiler
// babel.config.json
{
  "plugins": ["babel-plugin-react-compiler"]
}
// eslint.config.js — recomendado para detectar el código que el compilador no puede optimizar
// {
//   plugins: ["react-compiler"],
//   rules: { "react-compiler/react-compiler": "error" }
// }

Dos advertencias honestas. Primero, el compilador solo optimiza componentes que respetan las reglas de los hooks; las llamadas condicionales a hooks o las mutaciones durante el render se saltean la optimización o aparecen como errores de ESLint. Segundo, no elimina toda memoización manual: el trabajo costoso fuera de componentes, o los requisitos de estabilidad referencial de librerías de terceros, pueden seguir necesitando useMemo. Déjalo correr un tiempo en el codebase antes de borrar cada optimización manual; mide en lugar de asumir.

Migración a React 19

Actualizar es en su mayor parte mecánico, pero algunas eliminaciones afectan a codebases antiguas:

# Actualizar
npm install react@19 react-dom@19

# Actualizar los tipos
npm install -D @types/react@19 @types/react-dom@19

# Codemods automatizados (recetas oficiales de React)
npx codemod react/19/migration-recipe

# Migraciones a nivel de tipos
npx types-react-codemod@latest preset-19 .
// Cambios incompatibles a vigilar:
// 1. ref ahora es una prop — forwardRef queda deprecado (aún funciona)
// 2. defaultProps eliminado en componentes de función
// 3. string refs eliminados
// 4. propTypes eliminado
// 5. Contexto legacy (contextTypes / childContextTypes) eliminado
// 6. Module pattern factories y react-test-renderer deprecados

// Antes: defaultProps
// function Button({ size = "medium" }) { ... }
// Button.defaultProps = { size: "medium" };

// Después: parámetros por defecto
function Button({ size = "medium" }: { size?: string }) {
  return <button className={`btn btn-${size}`} />;
}

El codemod de migration recipe cubre las partes mecánicas (string refs, wrappers de forwardRef, APIs eliminadas), pero revisa el diff antes de commitear; los codemods no siempre distinguen un patrón deliberado de uno eliminable. Si tu codebase usa mucho propTypes o contexto legacy, reserva tiempo para el trabajo manual que esos codemods no pueden hacer. Para equipos que quieran una segunda opinión sobre qué modernizar, la guía de performance en React también cubre cuáles de estos patrones dañan el rendimiento de render hoy.

Buenas Prácticas

  • Dibuja la frontera servidor/cliente a propósito. Pon "use client" lo más abajo posible en el árbol. Una directiva a nivel de página convierte todo el subárbol en JavaScript de cliente y tira a la basura el beneficio del bundle.
  • Deja que los formularios carguen su propio estado. Combina useActionState + useFormStatus en lugar de elevar isSubmitting a un padre; el formulario es dueño de su ciclo de vida.
  • Empareja useOptimistic con una vía de revalidación. El valor optimista es efímero; solo se siente estable cuando el servidor vuelve a renderizar el componente con datos confirmados.
  • Activa primero la regla de ESLint del Compiler. eslint-plugin-react-compiler marca el código que el compilador no puede optimizar; arreglar esos warnings desbloquea las ganancias reales.
  • Precarga lo que la página realmente pinta. Las fuentes y la imagen hero se benefician; precargarlo todo solo compite con el fetch del HTML.

Errores Comunes

  • Crear la promesa dentro del consumidor de use(). use(getUser(id)) en el render dispara una petición por render. Crea la promesa en el padre o memoízala.
  • Pasar funciones o Dates por la frontera servidor/cliente. Solo cruzan valores serializables a JSON. Usa server actions para el comportamiento.
  • Poner useFormStatus en el padre del formulario. Lee el <form> que lo envuelve; en el padre devuelve pending: false eternamente.
  • Añadir "reactCompiler": true al tsconfig. El compilador es un plugin de Babel, no una opción de TypeScript; el flag no hace nada.
  • Esperar que el estado optimista persista tras resolver la action. Sin revalidatePath o datos frescos del servidor, el elemento optimista desaparece en silencio.
  • Confiar en las entradas de las actions. Las server actions son endpoints públicos, así que valida el FormData en el servidor, siempre.

Lectura Adicional

Preguntas frecuentes

¿Cuál es la diferencia entre componentes de servidor y de cliente?

Los componentes de servidor se ejecutan en el servidor durante el SSR o en tiempo de build. Pueden acceder a bases de datos, sistemas de archivos y librerías exclusivas de servidor, y no envían JavaScript al cliente. Los componentes de cliente corren en el navegador y pueden usar estado, efectos, manejadores de eventos y APIs del navegador. Un componente de servidor puede renderizar uno de cliente y pasarle props serializables; un componente de cliente no puede importar uno de servidor, aunque sí puede llamar a server actions.

¿Cuándo debería usar el hook use() en lugar de useEffect?

Usa use() cuando desenvuelvas una promesa y quieras que Suspense gestione el estado de carga. Mantén useEffect para efectos secundarios que necesitan cleanup — suscripciones, listeners, timers — o para fetches que deban reejecutarse en cada montaje. use() puede llamarse condicionalmente, útil para leer contexto; useEffect no.

¿Sigo necesitando useMemo y useCallback con el React Compiler?

Normalmente no dentro de componentes: el compilador memoiza según las dependencias reales. Mantén la memoización manual para trabajo fuera de componentes y para casos donde una librería de terceros exige estabilidad referencial. Mide después de activarlo; retira las memoizaciones manuales de forma gradual, no de golpe.

¿En qué se diferencian las actions de los manejadores onSubmit?

Las actions se acoplan al atributo nativo action del formulario, obtienen estado pendiente y reset del formulario gratis y (con un framework) funcionan sin JavaScript mediante progressive enhancement. onSubmit es un manejador de eventos solo de cliente: sirve para validar o interceptar un envío, pero no puede enviar sin JS y requiere gestión manual del estado.

¿Puedo usar las características de React 19 sin un framework?

Parcialmente. use(), useActionState, useOptimistic, useFormStatus, ref como prop y el React Compiler funcionan en una app de cliente normal. Los componentes de servidor y las server actions necesitan un framework con soporte RSC (Next.js, Remix o un setup propio) porque requieren infraestructura de renderizado en servidor.

¿Cómo migro de forwardRef a ref como prop?

Quita el wrapper forwardRef, añade ref a la interfaz de props (ref?: React.Ref<T>) y desestrúcturalo como cualquier otra prop. El codemod react/19/migration-recipe automatiza la mayor parte. Los componentes forwardRef existentes siguen funcionando, así que puedes migrar archivo a archivo.