Prop Drilling no es una razón para instalar Redux o Zustand
Pruebe cuatro razones comunes para agregar una biblioteca estatal al código React en funcionamiento: el contexto para el acceso profundo a propiedades, useState, useSyncExternalStore y el costo de volver a renderizar del contexto.
Pregúntale a un equipo por qué han incluido Redux, Zustand o MobX en su aplicación React y, por lo general, la primera respuesta es el problema del “prop drilling”. Es realmente molesto, pero no constituye una buena razón para añadir una dependencia, al igual que las tres justificaciones que suelen seguir. A continuación, cada una de esas cuatro razones se prueba con un pequeño ejemplo funcional, junto con lo que React ya ofrece para ese caso y la situación en la que una biblioteca de estado realmente se paga por sí misma.
Cuatro justificaciones comunes
Los argumentos suelen presentarse en un orden predecible:
- Pasar un valor a través de componentes que no lo utilizan es problemático, por lo que la aplicación necesita un almacén de estado.
- El estado del cliente que realmente cambia, como la opción para tema claro o oscuro, seguramente requiere una biblioteca.
- Para que todos los demás componentes que leen ese estado se actualicen automáticamente, sin tener que conectar las propiedades manualmente, es necesario utilizar una biblioteca.
Cada uno de ellos falla en cuanto se examina el código concreto.
Prop drilling: un problema que React resolvió hace años
El concepto de prop drilling consiste en pasar un valor a través de varias capas únicamente para que un componente profundamente anidado pueda leerlo, sin que ninguna de las capas intermedias lo utilice. Cuatro archivos pequeños ilustran este mecanismo. App almacena un objeto user en su estado y lo pasa a Dashboard.
// App.jsx
import { useState } from 'react';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return <Dashboard user={user} />;
}
export default App;
Dashboard no hace nada con user aparte de pasarlo a Sidebar.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard({ user }) {
return <Sidebar user={user} />;
}
export default Dashboard;
Sidebar hace lo mismo, desempaquetando dos campos para Avatar.
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar({ user }) {
return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
}
export default Sidebar;
Solo Avatar es el que realmente muestra los datos.
// components/Avatar.jsx
function Avatar({ name, avatarUrl }) {
return <img src={avatarUrl} alt={name} />;
}
export default Avatar;
Ni Dashboard.jsx ni Sidebar.jsx leen el valor de user para su propia lógica. Funcionan como intermediarios. Si avatarUrl se renombra a photoUrl, ambos archivos necesitan ser modificados aunque su comportamiento no haya cambiado, simplemente porque transmiten algo que no les pertenece.
El contexto proporciona el valor directamente
La solución integrada de React es el Context. Primero, se crea un objeto de contexto una sola vez en su propio módulo.
// context/UserContext.js
import { createContext } from 'react';
const UserContext = createContext(null);
export default UserContext;
Luego, App envuelve el subárbol en el Provider del contexto y pasa user como su valor, en lugar de transmitirlo como un prop.
// App.jsx
import { useState } from 'react';
import UserContext from './context/UserContext';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
export default App;
Dashboard y Sidebar se reducen a un mero diseño, sin ninguna mención al user.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard() {
return <Sidebar />;
}
export default Dashboard;
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar() {
return <Avatar />;
}
export default Sidebar;
Finalmente, Avatar lee el valor directamente.
// components/Avatar.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';
function Avatar() {
const user = useContext(UserContext);
return <img src={user.avatarUrl} alt={user.name} />;
}
export default Avatar;
Cada uno de los tres componentes tiene una función específica. createContext crea un canal independiente de las props. El Provider hace que user esté disponible para todo su subárbol al mismo tiempo. useContext(UserContext) lee el valor del Provider más cercano que coincida por encima del componente, omitiendo todas las capas intermedias. Los componentes intermedios dejan de hacer referencia a user porque nunca lo necesitaron. Como anécdota, React 19 también permite renderizar el objeto de contexto directamente como proveedor, pero la forma .Provider mostrada aquí sigue funcionando.
Por qué el argumento del “drilling de props” sobrevivió a su razón de ser
La historia explica por qué persiste este debate. Redux apareció en junio de 2015, creado por Dan Abramov y Andrew Clark, y se basa en la arquitectura Flux de Facebook: un almacén central único con reglas estrictas sobre cómo puede cambiar el estado. La API Context de React solo se convirtió en una función estable y oficialmente soportada en la versión 16.3, en marzo de 2018, casi tres años después. Durante los primeros años de Redux, un almacén era realmente la forma práctica de hacer que un valor fuera accesible desde cualquier parte del sistema sin tener que pasarlo por cada componente.
Eso dejó de ser cierto una vez que Context se estabilizó, pero la forma de enseñar no evolucionó. Los tutoriales siguieron utilizando el método de propagación de propiedades como ejemplo del problema a resolver, ya que es fácil de ilustrar en una pizarra, y ese hábito persistió mucho tiempo después de que desapareciera la razón original para ello.
No obstante, la perforación de pozos se refiere a la distribución, no al cambio. La siguiente pregunta es qué ocurre cuando el valor distribuido comienza a cambiar en el cliente.
Cambiar de estado es exactamente lo que hace useState
Tomemos un interruptor de tema: una sola bandera que contiene light o dark, la cual se invierte con un botón ubicado justo al lado del texto que lo muestra.
// components/SettingsPanel.jsx
import { useState } from 'react';
function SettingsPanel() {
const [theme, setTheme] = useState('light');
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export default SettingsPanel;
Al hacer clic en el botón se llama a setTheme; SettingsPanel se vuelve a renderizar con el nuevo valor y el párrafo se actualiza. No hay ninguna biblioteca involucrada, ni se necesita alguna. Tener un valor, cambiarlo y volver a renderizar el componente que lo contiene es precisamente para eso para lo cual existe useState, y todas las aplicaciones React lo hacen constantemente.
Por lo general, el ejemplo se presenta con un giro que realiza todo el trabajo real. La dificultad no radica en que el valor cambie, sino en que ese valor se lee en otro lugar. Imagine que el botón permanece dentro de SettingsPanel.jsx, mientras que Header.jsx y Sidebar.jsx, dos archivos no relacionados que ni importan ni son importados por SettingsPanel, también deben mostrar el tema actual. El estado creado con useState pertenece a una única instancia de componente. Nada fuera de ese componente puede leerlo o ser notificado sobre cambios, a menos que un ancestro compartido lo transmita, lo cual vuelve a plantear el problema de la propagación de propiedades.
Por lo tanto, useState maneja los cambios perfectamente bien. La pregunta abierta es cómo dos componentes sin un padre común pueden actualizarse automáticamente sin propiedades.
Compartir estado entre componentes no relacionados con useSyncExternalStore
Si ni Header ni Sidebar pueden poseer el valor, este debe encontrarse fuera del árbol de componentes: en una variable a nivel de módulo que se inicializa una sola vez, al importarse su archivo por primera vez, en lugar de dentro de alguna función de componente. React nunca ve que una variable simple sea reasignada, por lo que no puede volver a renderizar nada por sí mismo cuando ese valor cambia. Un pequeño módulo de almacén llena esa brecha.
// store/themeStore.js
import { useSyncExternalStore } from 'react';
let state = { theme: 'light' };
const listeners = new Set();
export function setTheme(theme) {
state = { ...state, theme };
listeners.forEach((listener) => listener());
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
return useSyncExternalStore(subscribe, () => state.theme);
}
El módulo contiene un objeto state y un Set de suscriptores. La función setTheme reemplaza el objeto state por uno nuevo y llama a cada suscriptor. Una función privada de registro agrega un suscriptor al conjunto y devuelve una función de limpieza para eliminarlo. La función useTheme conecta todo esto con React a través de useSyncExternalStore, un hook diseñado específicamente para estados que se encuentran fuera de los componentes pero son leídos por varios de ellos.
Este hook acepta dos argumentos. El primero, esa función de registro, indica a React cómo asociar una función de callback que debe ejecutarse cada vez que cambie el valor externo, y debe devolver una función correspondiente de cancelación para realizar la limpieza. El segundo, getSnapshot, representado aquí por la función de flecha () => state.theme, es el mecanismo mediante el cual React obtiene el valor actual cada vez que lo necesita.
Dos consumidores importan el hook, y ninguno conoce la existencia del otro.
// components/Header.jsx
import { useTheme } from '../store/themeStore';
function Header() {
const theme = useTheme();
return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
}
export default Header;
// components/Sidebar.jsx
import { useTheme } from '../store/themeStore';
function Sidebar() {
const theme = useTheme();
return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
}
export default Sidebar;
Un tercer componente contiene el botón e importa tanto el hook como setTheme desde la misma tienda.
// components/ThemeToggleButton.jsx
import { setTheme, useTheme } from '../store/themeStore';
function ThemeToggleButton() {
const theme = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
export default ThemeToggleButton;
Qué sucede, paso a paso
Cada uno de los pasos siguientes es observable si se ejecuta el código:
- Al montarse,
HeaderySidebarllaman cada uno auseTheme(). React llama agetSnapshotpara ambos, obtiene el valor'light'y los renderiza con él. - React también invoca la función de registro una vez por componente, por lo que se añaden dos devoluciones internas de React al conjunto compartido
listeners. Los componentes siguen siendo entradas independientes en ese conjunto.
ThemeToggleButton se llama a setTheme('dark'). Primero, state se reasigna a un objeto completamente nuevo, { theme: 'dark' }; se trata de código JavaScript puro sin nada específico de React.setTheme recorre el conjunto y llama a cada uno de los listeners. Dado que esos listeners pertenecen a React, al llamarlos se le indica a React que revise cada componente que esté registrado.getSnapshot para Header, ve 'dark' en lugar de 'light' y lo vuelve a renderizar. La misma verificación hace que Sidebar se vuelva a renderizar.Este setTheme no es el setter devuelto por useState. Es un código escrito a mano ordinario que realiza dos tareas en una sola llamada: actualizar la fuente de verdad y luego notificar a quienes estén escuchando.
No se pasaron propiedades a ningún lugar. Todo el cableado consiste en importaciones. Esto es, más o menos, lo que hace Zustand internamente: una versión compacta y empaquetada del mismo patrón de registro y notificación.
Consideraciones importantes
getSnapshotdebe devolver el mismo valor cuando no ha habido cambios. Es seguro devolver un tipo primitivo comostate.theme; crear un objeto o array nuevo en cada llamada hace que React piense que el almacén cambia constantemente.- Si renders en el servidor,
useSyncExternalStoreacepta un tercer argumento,getServerSnapshot, para el HTML inicial. El estado a nivel de módulo en el servidor también se comparte entre solicitudes, por lo que debe mantenerse fuera los datos por usuario.
Por qué Context no es la opción intermedia segura
Con un almacén de módulos, no hay nada que proporcionar. El state en themeStore.js nunca formó parte del árbol de componentes; los componentes acceden a él mediante una llamada a un hook, sin que intervenga ningún Provider ancestro. Envolver App en un ThemeProvider añadiría una capa que no transfiere nada.
El contexto tiene usos legítimos propios: delimitar un valor a un subárbol o reemplazar una dependencia en pruebas al renderizar un Provider diferente. Sin embargo, a menudo se recomienda como alternativa cautelosa a una biblioteca, y en ese papel cuesta más de lo que parece. Considere un único contexto que almacene tanto el tema como el carrito.
// context/AppContext.jsx
import { createContext, useState } from 'react';
const AppContext = createContext();
export function AppProvider({ children }) {
const [state, setState] = useState({
theme: 'light',
cart: ['book'],
});
return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}
export default AppContext;
Una pequeña etiqueta muestra únicamente el tema extraído de él.
// components/ThemeLabel.jsx
import { useContext } from 'react';
import AppContext from '../context/AppContext';
function ThemeLabel() {
const { theme } = useContext(AppContext);
return <span>{theme}</span>;
}
export default ThemeLabel;
useContext(AppContext) suscribe a ThemeLabel al objeto completo que contiene el Provider, y no solo a theme. Cuando cart cambia en otra parte, el Provider recibe un objeto state actualizado, y como la referencia es diferente, ThemeLabel se vuelve a renderizar también, aunque theme no haya cambiado y el componente nunca modifique cart. El contexto no cuenta con una forma integrada de indicar “despertarme solo cuando cambie este campo”; cada consumidor se activa ante cualquier cambio en el valor.
La tienda de la sección anterior ya evita esto. useTheme() lee un único fragmento a través de su getSnapshot, y un componente se vuelve a renderizar solo cuando el valor devuelto por ese fragmento difiere. Utilizar Context como solución intermedia ahorra un paso de instalación, pero genera más renders. Puedes suavizarlo dividiendo el estado en varios contextos más específicos, pero en ese caso estarás creando manualmente lo que una tienda basada en selectores te ofrece de forma gratuita.
Dónde realmente ganan su lugar las bibliotecas de estado
Nada de esto hace que Redux, Zustand o MobX sean inútiles. Las cuatro justificaciones mencionadas fallaron; las bibliotecas, en cambio, no. El problema del paso de propiedades se resuelve con Context. Cambiar el estado se soluciona mediante useState. Las actualizaciones automáticas en componentes no relacionados se manejan con useSyncExternalStore, que viene incluido en React. Y Context, el supuesto compromiso, resulta ser más costoso que un pequeño almacén para valores compartidos que cambian con frecuencia.
La verdadera necesidad de una biblioteca surge cuando el estado compartido tiene muchos escritores independientes en lugar de principalmente lectores, y cuando la cantidad de componentes que lo consumen supera lo que un puñado de almacenes de módulos manuales pueden mantener consistente. Coordinar actualizaciones, middleware, herramientas de desarrollo y un seguimiento predecible de cambios a esa escala es donde una biblioteca madura demuestra su valor, y merece un análisis detallado por sí misma.
Puntos clave
- Busque el Contexto, no una tienda de estado, cuando el único problema es pasar valores a través de capas que no los utilizan.
useStatees la herramienta adecuada para modificar el estado propiedad de un componente.- Para estados compartidos por componentes sin padre común, a menudo basta con una pequeña tienda de estado construida sobre
useSyncExternalStore. - Un único Contexto amplio vuelve a renderizar a todos los consumidores ante cualquier cambio; prefiera contextos más específicos o una tienda basada en selectores para datos que cambian con frecuencia.
- Adopte una biblioteca de estado cuando tenga muchos escritores independientes y un número creciente de lectores, no por el problema del “prop drilling”.
Lecturas relacionadas
- Cortando los componentes React grandes hasta un tamaño manejable — Aprenda siete patrones concretos de refactoring para descomponer componentes React sobrecargados al aislar el estado, la obtención de datos, los permisos y la lógica de carga en lugar de simplemente dividir archivos.
- Cómo React-Redux decide volver a renderizar: Selectores, igualdad y gancho tipados — Una guía de estudio sobre los aspectos internos de React-Redux: Provider, la igualdad de useSelector, selectores memorizados, connect(), gancho tipados con withTypes() y casos límite poco comunes.