Estado local de UI
El estado efímero de un solo componente: abierto o cerrado, hover, foco. Nace y muere con la vista. La colocación como principio, useState frente a useRef, y por qué elevarlo antes de tiempo es el pecado original del diseño de estado.
La primera clase de la taxonomía es la más humilde y la más frecuente: el estado que pertenece a un único componente, que ninguna otra parte de la app necesita ver, y que desaparece cuando la vista se desmonta. Un acordeón abierto, un botón en hover, el foco de un campo. Reconocerlo importa tanto como reconocer a los demás, porque el error de principiante no es olvidarlo: es ascenderlo a una categoría superior que no le corresponde.
- Definir el estado local por su ámbito y su tiempo de vida, no por su tipo.
- Elegir entre
useState,useReduceryuseRefsegún qué debe provocar un render. - Reconocer el mismo concepto en signals (
$state,createSignal) más allá de React. - Interiorizar la colocación: mantener el estado tan cerca de su uso como sea posible.
La firma del estado local: ámbito y tiempo de vida
Un estado es local cuando cumple dos condiciones a la vez: su ámbito es un solo componente (nadie más lo lee) y su tiempo de vida coincide con el del componente (se crea al montar, se destruye al desmontar). No lo define su contenido —un booleano puede ser local o global—; lo definen esas dos coordenadas.
function Acordeon({ titulo, children }: Props) {
const [abierto, setAbierto] = useState(false); // nace aqui, muere aqui
return (
<section>
<button onClick={() => setAbierto((v) => !v)}>{titulo}</button>
{abierto && <div>{children}</div>}
</section>
);
}
El booleano abierto no significa nada fuera de este Acordeon. Dos acordeones en la misma página tienen dos estados independientes, sin coordinación ni conflicto. Ese aislamiento no es un detalle de implementación: es la propiedad que hace al estado local trivial de razonar. No hay carreras, no hay obsolescencia, no hay que invalidarlo — porque nadie más lo comparte.
flowchart LR M[montar componente] --> I[inicializar estado local] I --> U[interaccion del usuario] U --> R[re-render con el nuevo valor] R --> U U --> D[desmontar vista] D --> G[el estado se destruye] style I fill:#89b4fa,color:#11111b style R fill:#a6e3a1,color:#11111b style G fill:#f38ba8,color:#11111b
El diagrama captura la firma: el estado no sobrevive al ciclo de vida de la vista. Si necesitas que persista más allá —recordar el acordeón abierto tras recargar— ya no es estado local; es persistencia (Nivel 36) o estado de URL (lección 4 de este nivel). Ese es el primer corte de la taxonomía: no preguntas qué guarda el dato, sino cuánto debe vivir.
No todo estado local provoca un render
useState es el caso por defecto, pero el ámbito local admite tres herramientas con semánticas distintas:
const [valor, setValor] = useState(0); // cambia -> re-render
const [estado, dispatch] = useReducer(fn, init); // transiciones nombradas
const idInterval = useRef<number | null>(null); // muta SIN re-render
La distinción crítica es entre useState y useRef. Ambos guardan un valor que sobrevive entre renders, pero useState agenda un render cuando cambia y useRef no. El estado local que la UI muestra (abierto, texto del campo) va en useState; el estado local que solo necesitas recordar entre renders sin pintarlo (un temporizador, la posición previa del scroll, un nodo del DOM) va en useRef. Confundirlos produce los dos bugs clásicos: renders infinitos por escribir en useState donde bastaba una ref, o UI congelada por mutar una ref donde se esperaba un render.
useReducer no cambia el ámbito —sigue siendo local— sino la disciplina: cuando las transiciones del componente son varias y se relacionan (un asistente por pasos, un campo con validación y estados de envío), un reducer local convierte mutaciones dispersas en transiciones nombradas y testeables. Es la máquina de estados (Niveles 9-16) en miniatura, sin salir del componente.
type Accion = { tipo: 'siguiente' } | { tipo: 'atras' } | { tipo: 'reset' };
function pasos(estado: number, accion: Accion): number {
switch (accion.tipo) {
case 'siguiente': return Math.min(estado + 1, 3);
case 'atras': return Math.max(estado - 1, 0);
case 'reset': return 0;
}
}
// el estado sigue siendo local; solo cambia como se muta: por acciones
const [paso, dispatch] = useReducer(pasos, 0);
Cuesta aceptarlo, pero una ref es estado local: un valor mutable que persiste durante toda la vida del componente. Simplemente no participa en la reactividad. La regla operativa: si al cambiar el valor la pantalla debe cambiar, es useState; si solo necesitas leerlo más tarde sin repintar, es useRef. Almacenar en useState algo que nunca se muestra malgasta renders; guardar en useRef algo que sí se muestra rompe la UI de forma silenciosa.
No todo lo que parece estado lo es. Si un valor se puede calcular a partir de otro estado o de las props en cada render, no lo guardes: derívalo. Duplicarlo en un useState crea una segunda fuente de la verdad que hay que sincronizar a mano y que tarde o temprano discrepará.
// ANTI-PATRON: estado derivado duplicado, hay que recordar actualizar el total
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
// CORRECTO: derivar en el render, sin estado extra ni sincronizacion
const [items, setItems] = useState<Item[]>([]);
const total = items.length; // siempre consistente por construccion
La mejor pieza de estado es la que no existe. Antes de añadir un useState, pregúntate si ese valor no será, en realidad, una función de otro estado que ya tienes. El estado derivado —calculado en cada render— nunca se desincroniza porque no se almacena: se recomputa. Reservar useState solo para lo genuinamente independiente mantiene el número de fuentes de la verdad al mínimo, y con él, el número de formas en que la UI puede contradecirse a sí misma.
Ámbito de uno
Ningún otro componente lee este dato. Su significado empieza y termina dentro de una función de componente.
Vida efímera
Se crea al montar y se destruye al desmontar. No sobrevive a la vista ni a una recarga de la página.
Cero coordinación
Sin carreras, sin obsolescencia, sin invalidación. Nadie más lo comparte, así que no hay nada que sincronizar.
Barato de razonar
Cualquier bug relacionado vive en un solo archivo. El ámbito acota el razonamiento a unas pocas líneas.
El mismo concepto fuera de React
El estado local no es una idea de React; es una idea del ámbito léxico. En los modelos de signals de 2026 el patrón es idéntico, solo cambia la sintaxis: un valor reactivo declarado dentro del componente o del setup.
// Solid: el signal vive en el ambito de la funcion componente
function Acordeon() {
const [abierto, setAbierto] = createSignal(false);
return <button onClick={() => setAbierto((v) => !v)}>Alternar</button>;
}
En Svelte 5, let abierto = $state(false) dentro de un componente; en Vue, const abierto = ref(false) en el setup. Todos expresan lo mismo: un dato cuyo ámbito es el componente y cuyo tiempo de vida es su instancia. La lección transversal —y el motivo de toda esta taxonomía— es que la clase de estado precede a la librería. Primero decides que algo es local; después eliges cómo lo expresas.
Que el modelo sea de grano fino (signals, con reactividad quirúrgica) o de re-render por componente (React) cambia el rendimiento y la ergonomía, pero no la clasificación: un signal declarado dentro de un componente sigue siendo estado local, con el mismo ámbito y el mismo tiempo de vida que un useState. La taxonomía es anterior e independiente del motor de reactividad — por eso viaja intacta entre frameworks.
El pecado original: elevar antes de tiempo
La decisión más consecuente sobre un estado no es qué librería usar, sino dónde vive. El principio de colocación dice: un estado debe residir en el componente más profundo que lo necesita, y solo ascender cuando dos hermanos genuinamente distintos deban compartirlo — hasta su primer ancestro común y ni un nivel más. Elevar antes de tiempo (meter en un store global el booleano de un modal, la pestaña activa de un widget, el hover de una tarjeta) es el pecado original del diseño de estado, y tiene un coste triple. Primero, acoplamiento: componentes que eran independientes ahora dependen de una pieza central y no se pueden mover ni reutilizar sin arrastrarla. Segundo, rendimiento: un cambio en ese store puede invalidar y re-renderizar ramas enteras que no tenían nada que ver con el modal. Tercero, y el más caro, cognitivo: cada dato en el ámbito global es un dato que quien lee el código debe considerar como posible causa de cualquier bug, en cualquier parte. El estado local es barato precisamente porque su ámbito acota el razonamiento a un archivo. Globalizarlo por comodidad convierte un problema de una línea en una superficie de fallo de toda la app. La pregunta correcta nunca es “¿lo pongo en el store?”, sino “¿quién más necesita esto de verdad?” — y la respuesta honesta, la inmensa mayoría de las veces, es “nadie”.
- Toma un componente real con
useStatey pregúntate, por cada estado: ¿alguien fuera lo lee? Si no, confirma que está en el sitio más profundo posible. - Encuentra un estado que solo se use entre renders sin mostrarse y conviértelo de
useStateauseRef; observa cuántos renders eliminas. - Refactoriza un componente con tres o cuatro
useStaterelacionados a un únicouseReducerlocal con acciones nombradas. - Busca un estado local que hayas metido en un store global “por si acaso” y bájalo a su componente; nota el acoplamiento que desaparece.
- Separa lo que se muestra (
useState) de lo que solo se recuerda entre renders (useRef), y confirma que no guardas nada que puedas derivar.