El problema que todo proyecto React encuentra tarde o temprano
Al empezar con React, cada componente suele gestionar sus propios datos con useState y todo parece sencillo. El problema aparece en cuanto dos componentes que viven en ramas distintas de la interfaz necesitan los mismos datos. Un contador de elementos en la cabecera debe reflejar lo que hay en una lista; un formulario debe añadir elementos a esa misma lista; un filtro en una barra lateral debe cambiar lo que se muestra en el panel principal. Si cada componente guarda su propia copia, las copias se desincronizan y la interfaz empieza a mostrar información contradictoria.
Ante esa situación, mucha gente recurre inmediatamente a soluciones pesadas: una librería de gestión de estado global, un contexto para todo o incluso sincronizar copias con efectos. Casi siempre existe una solución más simple, integrada en el propio modelo de React: elevar el estado.
En este artículo vamos a entender en profundidad esta técnica, que la documentación oficial llama lifting state up. Construiremos paso a paso una pequeña aplicación de lista de lectura, veremos cómo mover el estado al lugar adecuado, cómo pasarlo hacia abajo con props y cómo permitir que los componentes hijos lo modifiquen mediante funciones. Después analizaremos los componentes controlados, el estado derivado, los errores más frecuentes y cuándo conviene dar el salto a useReducer, Context o una librería externa.
Los ejemplos están escritos para React 19, cuya versión estable más reciente es la 19.3, aunque la técnica funciona igual en cualquier versión moderna basada en hooks.
Preparar el proyecto
Para seguir los ejemplos basta con un proyecto React creado con Vite, la herramienta de referencia hoy para iniciar aplicaciones de una sola página:
npm create vite@latest lista-lectura -- --template react
cd lista-lectura
npm install
npm run dev
VITE ready in 312 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
La estructura de componentes que vamos a construir es la siguiente:
src/
├── App.jsx
├── components/
│ ├── ContadorLibros.jsx
│ ├── ListaLibros.jsx
│ └── FormularioLibro.jsx
└── main.jsx
El punto de partida: estado atrapado en un componente
Nuestra aplicación tiene tres piezas. ContadorLibros muestra cuántos libros hay en la lista. ListaLibros los muestra uno debajo de otro. FormularioLibro permite añadir un libro nuevo. El componente App simplemente los coloca en pantalla.
Un primer intento natural es dejar que ListaLibros gestione los datos, porque al fin y al cabo es quien los muestra:
// components/ListaLibros.jsx
import { useState } from "react";
export default function ListaLibros() {
const [libros, setLibros] = useState([
{ id: 1, titulo: "Cien años de soledad" },
{ id: 2, titulo: "Rayuela" },
{ id: 3, titulo: "La sombra del viento" },
]);
return (
<ul>
{libros.map((libro) => (
<li key={libro.id}>{libro.titulo}</li>
))}
</ul>
);
}
// components/ContadorLibros.jsx
export default function ContadorLibros() {
return <p>Tienes ? libros en tu lista</p>;
}
// App.jsx
import ContadorLibros from "./components/ContadorLibros";
import ListaLibros from "./components/ListaLibros";
import FormularioLibro from "./components/FormularioLibro";
export default function App() {
return (
<main>
<h1>Mi lista de lectura</h1>
<ContadorLibros />
<ListaLibros />
<FormularioLibro />
</main>
);
}
El problema salta a la vista. ContadorLibros necesita saber cuántos libros hay, pero ese dato vive dentro de ListaLibros, y en React los datos no pueden fluir de un hermano a otro. Lo mismo ocurre con FormularioLibro: no tiene forma de añadir un elemento a una lista que pertenece a otro componente.
La idea central: subir el estado al ancestro común
React sigue un flujo de datos unidireccional: la información viaja de los componentes padres a los hijos a través de las props, nunca en sentido contrario ni entre hermanos. Esta restricción, que al principio parece una limitación, es precisamente lo que hace que las aplicaciones React sean predecibles: siempre se puede saber de dónde viene cada dato.
La solución consiste en mover el estado hacia arriba en el árbol, hasta el ancestro común más cercano de todos los componentes que lo necesitan. Desde ahí, el ancestro reparte los datos a cada hijo mediante props, junto con las funciones necesarias para modificarlos.
Podemos visualizar el cambio en el árbol de componentes:
Antes Después
App App (estado: libros)
├── ContadorLibros ├── ContadorLibros ← libros
├── ListaLibros (estado) ├── ListaLibros ← libros
└── FormularioLibro └── FormularioLibro ← función para añadir
En nuestro caso, el único ancestro común de los tres componentes es App, así que es ahí donde debe vivir la lista de libros. El proceso tiene tres pasos: mover el estado al padre, pasar los datos hacia abajo y pasar las funciones de actualización hacia abajo.
Paso 1: mover el estado al padre
Empezamos trasladando el useState desde ListaLibros hasta App:
// App.jsx
import { useState } from "react";
import ContadorLibros from "./components/ContadorLibros";
import ListaLibros from "./components/ListaLibros";
import FormularioLibro from "./components/FormularioLibro";
export default function App() {
const [libros, setLibros] = useState([
{ id: 1, titulo: "Cien años de soledad" },
{ id: 2, titulo: "Rayuela" },
{ id: 3, titulo: "La sombra del viento" },
]);
return (
<main>
<h1>Mi lista de lectura</h1>
<ContadorLibros />
<ListaLibros />
<FormularioLibro />
</main>
);
}
Si ejecutas la aplicación en este punto, ListaLibros fallará porque la variable libros ya no existe dentro de él. Es un estado intermedio esperado: el dato está en el lugar correcto, pero todavía no llega a quien lo necesita.
Paso 2: pasar los datos hacia abajo con props
Ahora App entrega la lista a los componentes que la muestran. Las props son simplemente argumentos que un componente padre pasa a sus hijos:
// App.jsx (fragmento)
<ContadorLibros libros={libros} />
<ListaLibros libros={libros} />
// components/ContadorLibros.jsx
export default function ContadorLibros({ libros }) {
const total = libros.length;
return (
<p>
Tienes {total} {total === 1 ? "libro" : "libros"} en tu lista
</p>
);
}
// components/ListaLibros.jsx
export default function ListaLibros({ libros }) {
if (libros.length === 0) {
return <p>Tu lista está vacía. ¡Añade tu primer libro!</p>;
}
return (
<ul>
{libros.map((libro) => (
<li key={libro.id}>{libro.titulo}</li>
))}
</ul>
);
}
Ambos componentes se han vuelto más simples. Ya no gestionan estado: reciben datos y los muestran. Este tipo de componentes, a veces llamados presentacionales, son más fáciles de entender, de reutilizar y de probar, porque su resultado depende únicamente de sus props.
Paso 3: pasar funciones para modificar el estado
Queda el formulario, que no solo necesita leer la lista, sino modificarla. Como el estado vive en App, solo App puede cambiarlo. La forma de permitir que un hijo lo haga es pasarle una función. Cuando el hijo la invoca, en realidad está pidiendo al padre que actualice su estado.
Una buena práctica es no pasar directamente setLibros, sino una función con un nombre que exprese la intención y que encapsule la lógica de actualización:
// App.jsx
import { useState } from "react";
import ContadorLibros from "./components/ContadorLibros";
import ListaLibros from "./components/ListaLibros";
import FormularioLibro from "./components/FormularioLibro";
export default function App() {
const [libros, setLibros] = useState([
{ id: 1, titulo: "Cien años de soledad" },
{ id: 2, titulo: "Rayuela" },
{ id: 3, titulo: "La sombra del viento" },
]);
function agregarLibro(titulo) {
setLibros((anteriores) => [
...anteriores,
{ id: crypto.randomUUID(), titulo },
]);
}
return (
<main>
<h1>Mi lista de lectura</h1>
<ContadorLibros libros={libros} />
<ListaLibros libros={libros} />
<FormularioLibro onAgregar={agregarLibro} />
</main>
);
}
// components/FormularioLibro.jsx
import { useState } from "react";
export default function FormularioLibro({ onAgregar }) {
const [titulo, setTitulo] = useState("");
function manejarEnvio(evento) {
evento.preventDefault();
const limpio = titulo.trim();
if (!limpio) return;
onAgregar(limpio);
setTitulo("");
}
return (
<form onSubmit={manejarEnvio}>
<input
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Título del libro"
aria-label="Título del libro"
/>
<button type="submit">Añadir</button>
</form>
);
}
Hay varios detalles importantes en este código. La actualización usa la forma funcional setLibros((anteriores) => ...), que garantiza trabajar siempre con el valor más reciente del estado, incluso si se producen varias actualizaciones seguidas. Se crea un array nuevo con el operador de propagación en lugar de modificar el existente, porque React detecta los cambios comparando referencias. Y el identificador se genera con crypto.randomUUID(), disponible en todos los navegadores modernos, en lugar de usar el índice del array.
Por convención, las props que reciben funciones para notificar eventos se nombran con el prefijo on, como onAgregar, igual que los eventos nativos onClick u onChange. Así, quien lee el componente entiende al instante que se trata de un callback.
Con esto, los tres componentes comparten una única fuente de datos. Al añadir un libro, el estado de App cambia, React vuelve a renderizar el componente y tanto el contador como la lista reciben automáticamente la nueva información.
Una única fuente de verdad
El principio que subyace a esta técnica es que cada dato debe tener una única fuente de verdad. Para cada pieza de estado, debe existir exactamente un componente que la posee y la modifica. El resto de componentes la reciben como props.
Este principio evita una clase entera de errores. Imagina la alternativa: que el contador mantuviera su propio número y se incrementara cada vez que el formulario añade un libro. Funcionaría hasta que alguien añadiera la posibilidad de eliminar libros y olvidara actualizar el contador, o hasta que la lista se cargara desde un servidor. Con dos fuentes de verdad, la sincronización se convierte en una responsabilidad manual y propensa a fallos.
Ampliemos la aplicación con la posibilidad de marcar libros como leídos y eliminarlos. Todo se resuelve añadiendo funciones en el componente propietario:
// App.jsx (fragmento)
const [libros, setLibros] = useState([
{ id: 1, titulo: "Cien años de soledad", leido: true },
{ id: 2, titulo: "Rayuela", leido: false },
]);
function alternarLeido(id) {
setLibros((anteriores) =>
anteriores.map((libro) =>
libro.id === id ? { ...libro, leido: !libro.leido } : libro
)
);
}
function eliminarLibro(id) {
setLibros((anteriores) => anteriores.filter((libro) => libro.id !== id));
}
// En el JSX
<ListaLibros
libros={libros}
onAlternarLeido={alternarLeido}
onEliminar={eliminarLibro}
/>;
// components/ListaLibros.jsx
export default function ListaLibros({ libros, onAlternarLeido, onEliminar }) {
return (
<ul>
{libros.map((libro) => (
<li key={libro.id}>
<label>
<input
type="checkbox"
checked={libro.leido}
onChange={() => onAlternarLeido(libro.id)}
/>
{libro.titulo}
</label>
<button onClick={() => onEliminar(libro.id)}>
Eliminar
</button>
</li>
))}
</ul>
);
}
La lista sigue sin tener estado propio. Solo informa al padre de lo que el usuario ha hecho, y el padre decide cómo cambia el estado.
Estado derivado: no guardes lo que puedes calcular
Al elevar el estado surge con frecuencia la tentación de guardar también valores que se pueden calcular a partir de él, como el número de libros leídos o la lista filtrada. Es un error habitual que conviene evitar.
Si un valor se puede calcular a partir de las props o del estado existente, se calcula durante el renderizado. A esto se le llama estado derivado:
// components/ContadorLibros.jsx
export default function ContadorLibros({ libros }) {
const total = libros.length;
const leidos = libros.filter((libro) => libro.leido).length;
const porcentaje = total === 0 ? 0 : Math.round((leidos / total) * 100);
return (
<p>
Has leído {leidos} de {total} libros ({porcentaje} %)
</p>
);
}
Guardar leidos en un useState aparte obligaría a mantenerlo sincronizado con la lista en cada operación, justo el tipo de duplicación que queremos eliminar. Calcularlo en cada renderizado es más simple y, salvo cálculos realmente costosos, no tiene un impacto de rendimiento apreciable.
Además, con el React Compiler, que alcanzó su versión estable 1.0, muchas memoizaciones que antes había que hacer a mano con useMemo o useCallback se aplican automáticamente durante la compilación, lo que reduce todavía más la necesidad de optimizar este tipo de cálculos manualmente.
Componentes controlados: estado que vive en el padre
El patrón de elevar el estado tiene un pariente cercano: los componentes controlados en React y no controlados. Un componente es controlado cuando sus datos más importantes no los gestiona él mismo, sino que los recibe como props y notifica los cambios mediante callbacks. Un componente no controlado, en cambio, guarda su propio estado interno.
Los campos de formulario son el ejemplo más claro. En FormularioLibro, el input es controlado: su valor viene del estado titulo y cada cambio se notifica con onChange. Pero la misma idea se aplica a componentes propios.
Supongamos que queremos un filtro que permita mostrar todos los libros, solo los leídos o solo los pendientes, y que tanto la lista como el contador respeten ese filtro. El filtro seleccionado es un dato que necesitan varios componentes, así que también se eleva a App:
// components/FiltroLibros.jsx
const OPCIONES = [
{ valor: "todos", etiqueta: "Todos" },
{ valor: "leidos", etiqueta: "Leídos" },
{ valor: "pendientes", etiqueta: "Pendientes" },
];
export default function FiltroLibros({ valor, onCambiar }) {
return (
<div role="group" aria-label="Filtrar libros">
{OPCIONES.map((opcion) => (
<button
key={opcion.valor}
aria-pressed={valor === opcion.valor}
onClick={() => onCambiar(opcion.valor)}
>
{opcion.etiqueta}
</button>
))}
</div>
);
}
// App.jsx (fragmento)
const [filtro, setFiltro] = useState('todos');
const visibles = libros.filter((libro) => {
if (filtro === 'leidos') return libro.leido;
if (filtro === 'pendientes') return !libro.leido;
return true;
});
<FiltroLibros valor={filtro} onCambiar={setFiltro} />
<ListaLibros libros={visibles} onAlternarLeido={alternarLeido} onEliminar={eliminarLibro} />
FiltroLibros es un componente totalmente controlado: no sabe nada de libros, solo muestra opciones y avisa cuando el usuario elige una. Esto lo hace reutilizable en cualquier otra parte de la aplicación, y la lista visible se calcula como estado derivado a partir del estado elevado.
Dónde colocar el estado: ni más arriba ni más abajo de lo necesario
Elevar el estado no significa subirlo siempre a la raíz de la aplicación. La regla es colocarlo en el ancestro común más cercano de los componentes que lo necesitan, ni más arriba ni más abajo.
Si el estado sube más de lo necesario, aparecen dos problemas. Por un lado, cada cambio provoca el renderizado de componentes que no lo usan. Por otro, el código se vuelve más difícil de seguir, porque el estado vive lejos de donde se utiliza.
Por eso es igual de importante el movimiento contrario, a veces llamado colocación del estado: si un dato solo lo usa un componente, debe vivir dentro de ese componente. En nuestro ejemplo, el texto que el usuario está escribiendo en el formulario solo interesa al propio formulario, así que permanece en FormularioLibro. Solo cuando se envía, el título pasa al padre a través de onAgregar.
Una forma práctica de decidir dónde va cada estado es hacerse tres preguntas:
- ¿Qué componentes leen este dato?
- ¿Qué componentes lo modifican?
- ¿Cuál es el ancestro común más cercano de todos ellos?
La respuesta a la tercera pregunta es el lugar donde debe vivir el estado.
Tipado con TypeScript
En proyectos medianos y grandes, TypeScript ayuda mucho a mantener coherentes las props y los callbacks que viajan entre componentes. Un error tipográfico en el nombre de una prop o un callback con la firma equivocada se detectan antes de ejecutar la aplicación:
// types.ts
export interface Libro {
id: string;
titulo: string;
leido: boolean;
}
// components/ListaLibros.tsx
import type { Libro } from "../types";
interface ListaLibrosProps {
libros: Libro[];
onAlternarLeido: (id: string) => void;
onEliminar: (id: string) => void;
}
export default function ListaLibros({
libros,
onAlternarLeido,
onEliminar,
}: ListaLibrosProps) {
return (
<ul>
{libros.map((libro) => (
<li key={libro.id}>
<input
type="checkbox"
checked={libro.leido}
onChange={() => onAlternarLeido(libro.id)}
/>
{libro.titulo}
<button onClick={() => onEliminar(libro.id)}>
Eliminar
</button>
</li>
))}
</ul>
);
}
Definir explícitamente las props deja claro el contrato de cada componente: qué datos necesita y qué eventos puede emitir.
Cuando el estado crece: useReducer
A medida que se añaden operaciones, el componente propietario del estado acumula funciones como agregarLibro, alternarLeido, eliminarLibro o renombrarLibro. Cuando la lógica de actualización empieza a crecer, el hook useReducer permite centralizarla en una única función pura, fuera del componente:
// librosReducer.js
export function librosReducer(libros, accion) {
switch (accion.tipo) {
case "agregado":
return [
...libros,
{
id: crypto.randomUUID(),
titulo: accion.titulo,
leido: false,
},
];
case "alternado":
return libros.map((l) =>
l.id === accion.id ? { ...l, leido: !l.leido } : l
);
case "eliminado":
return libros.filter((l) => l.id !== accion.id);
default:
throw new Error(`Acción desconocida: ${accion.tipo}`);
}
}
// App.jsx (fragmento)
import { useReducer } from 'react';
import { librosReducer } from './librosReducer';
const [libros, dispatch] = useReducer(librosReducer, []);
<FormularioLibro onAgregar={(titulo) => dispatch({ tipo: 'agregado', titulo })} />
<ListaLibros
libros={libros}
onAlternarLeido={(id) => dispatch({ tipo: 'alternado', id })}
onEliminar={(id) => dispatch({ tipo: 'eliminado', id })}
/>
El estado sigue elevado exactamente en el mismo lugar; lo que cambia es cómo se organiza la lógica. Un reducer es además muy fácil de probar de forma aislada, porque es una función pura que recibe un estado y una acción y devuelve el nuevo estado:
import { describe, it, expect } from "vitest";
import { librosReducer } from "./librosReducer";
describe("librosReducer", () => {
it("elimina un libro por su id", () => {
const inicial = [{ id: "a", titulo: "Rayuela", leido: false }];
expect(librosReducer(inicial, { tipo: "eliminado", id: "a" })).toEqual(
[]
);
});
});
El límite de la técnica: prop drilling y sus alternativas
Elevar el estado funciona muy bien mientras los componentes que lo necesitan están cerca en el árbol. El problema aparece cuando el ancestro común está muchos niveles por encima y las props tienen que atravesar componentes intermedios que no las usan, solo para pasarlas al siguiente nivel. Este fenómeno, conocido como prop drilling en React, resulta fácil de reconocer:
function App() {
const [usuario, setUsuario] = useState(null);
return <Pagina usuario={usuario} />;
}
function Pagina({ usuario }) {
return <Barra usuario={usuario} />; // no usa usuario, solo lo reenvía
}
function Barra({ usuario }) {
return <MenuUsuario usuario={usuario} />; // tampoco lo usa
}
function MenuUsuario({ usuario }) {
return <span>{usuario?.nombre ?? "Invitado"}</span>;
}
Atravesar dos o tres niveles es perfectamente aceptable y, de hecho, hace explícito el flujo de datos. Cuando la cadena se alarga mucho, existen varias alternativas.
La primera, a menudo olvidada, es la composición. En lugar de pasar datos a través de componentes intermedios, se pasan directamente los componentes ya construidos como children:
function App() {
const [usuario, setUsuario] = useState(null);
return (
<Pagina>
<Barra>
<MenuUsuario usuario={usuario} />
</Barra>
</Pagina>
);
}
La segunda es la Context API de React, que permite que un componente proporcione un valor a todo su subárbol y que cualquier descendiente lo lea sin props intermedias:
import { createContext, use } from "react";
const UsuarioContext = createContext(null);
function App() {
const [usuario, setUsuario] = useState({ nombre: "Marta" });
return (
<UsuarioContext value={usuario}>
<Pagina />
</UsuarioContext>
);
}
function MenuUsuario() {
const usuario = use(UsuarioContext);
return <span>{usuario?.nombre ?? "Invitado"}</span>;
}
En React 19, un contexto se puede renderizar directamente como proveedor, sin necesidad de UsuarioContext.Provider, y la nueva API use permite leerlo, aunque useContext sigue funcionando. Context es ideal para datos que muchos componentes necesitan y que cambian con poca frecuencia, como el usuario autenticado, el tema o el idioma. Conviene no abusar de él para todo, porque cada cambio en el valor provoca el renderizado de todos los componentes que lo consumen.
La tercera opción son las librerías de gestión de estado, como Zustand, Redux Toolkit o Jotai, útiles cuando hay mucho estado compartido entre partes muy alejadas de la aplicación, actualizaciones frecuentes o necesidades avanzadas como herramientas de depuración y persistencia. Y para los datos que vienen de un servidor, librerías como TanStack Query resuelven la caché, la sincronización y los estados de carga mucho mejor que cualquier estado manual.
La recomendación práctica es seguir este orden: estado local, estado elevado, composición, Context y, solo cuando el problema lo justifique, una librería externa.
Errores comunes al elevar el estado
Hay varios errores que aparecen con frecuencia al aplicar esta técnica.
El primero es duplicar el estado en el hijo. Ocurre cuando un componente recibe un valor por props y lo copia en su propio useState:
// Incorrecto: la copia no se actualiza cuando cambia la prop
function ListaLibros({ libros }) {
const [lista, setLista] = useState(libros);
return lista.map((l) => <p key={l.id}>{l.titulo}</p>);
}
El valor inicial solo se usa en el primer renderizado, así que si el padre cambia la lista, el hijo seguirá mostrando la antigua. La solución es usar directamente la prop.
El segundo es mutar el estado en lugar de crear uno nuevo:
// Incorrecto: modifica el array existente
function agregarLibro(titulo) {
libros.push({ id: crypto.randomUUID(), titulo });
setLibros(libros);
}
Como la referencia del array no cambia, React puede no detectar la actualización y la interfaz no se renderiza. Hay que crear siempre un array u objeto nuevo.
El tercero es usar el índice como key en listas que cambian. Cuando se añaden, eliminan o reordenan elementos, los índices cambian y React puede reutilizar mal los componentes, mezclando el estado interno de unos elementos con otros. React avisa de ello en la consola cuando falta la clave:
Each child in a list should have a unique "key" prop.
Check the render method of `ListaLibros`.
Usa siempre un identificador estable y único de cada elemento.
El cuarto es llamar al callback en lugar de pasarlo. Escribir onClick={onEliminar(libro.id)} ejecuta la función durante el renderizado, lo que provoca actualizaciones inesperadas o incluso bucles infinitos. La forma correcta es envolverla en una función flecha: onClick={() => onEliminar(libro.id)}.
El quinto es sincronizar estados con efectos. Si te encuentras escribiendo un useEffect que copia un valor de un estado a otro cada vez que cambia, casi siempre es señal de que el estado debería estar elevado o calcularse como estado derivado.
Conclusiones
Elevar el estado es una de las técnicas más importantes de React y, a la vez, una de las más sencillas. Cuando varios componentes necesitan los mismos datos, la solución consiste en colocar el estado en su ancestro común más cercano, pasar los datos hacia abajo mediante props y entregar a los hijos funciones para notificar los cambios.
Este enfoque respeta el flujo de datos unidireccional de React y garantiza una única fuente de verdad para cada dato, lo que elimina toda una categoría de errores de sincronización. Los componentes que reciben los datos se vuelven más simples, reutilizables y fáciles de probar, y los valores que pueden calcularse a partir del estado se obtienen como estado derivado en lugar de almacenarse por duplicado.
La técnica también tiene límites claros. Cuando la lógica de actualización crece, useReducer ayuda a organizarla sin mover el estado de sitio. Cuando las props deben atravesar demasiados niveles, la composición, la Context API o, en casos justificados, una librería de gestión de estado ofrecen alternativas.
Antes de recurrir a soluciones complejas, conviene preguntarse siempre si el problema se resuelve simplemente colocando el estado en el lugar adecuado. En la gran mayoría de los casos, la respuesta es sí.
