Módulo 3: State With Usestate

El hook `useState`

Descripción

Ya sabes qué es el estado (memoria propia que persiste y, al cambiar, repinta) y cuándo usarlo (para lo que el componente posee y cambia). Esta lección baja a la herramienta con la que lo declaras: el hook useState. Es la línea de código que le pide a React una pieza de estado, y aunque cabe en un renglón, cada parte de ella tiene un porqué que conviene entender de una vez, porque lo escribirás miles de veces.

La línea es esta:

const [query, setQuery] = useState('');

Vamos a leerla de derecha a izquierda, que es como se entiende. useState('') es una llamada a una función de React —un hook— a la que le das el valor inicial del estado (aquí, la cadena vacía ''). Esa llamada devuelve un par: dos cosas juntas, en un array de dos posiciones. La primera es el valor actual del estado; la segunda es la función para cambiarlo (el setter). El const [query, setQuery] = ... de la izquierda es desestructuración de array: saca la primera posición y la llama query, saca la segunda y la llama setQuery. Los nombres los eliges tú, pero la convención es firme: el estado se llama x y su setter setX. Así, query/setQuery, count/setCount, expanded/setExpanded.

Dos propiedades de useState gobiernan cómo se comporta, y las dos las vas a ver ejecutadas. Primera: el valor inicial se usa solo en el primer render. Ese '' no es "el valor del estado siempre"; es "con qué arranca la primera vez". En los renders siguientes, useState ignora ese argumento y te devuelve lo que ya hay guardado en la libreta. Segunda: el setter guarda el valor nuevo y dispara un re-render. setQuery('mo') no cambia la variable query de esta pasada; reescribe la libreta y le pide a React que vuelva a ejecutar el componente.

Conexión con el módulo. Esta lección es la que te pone a escribir estado; todo lo que sigue (snapshot, re-render, updater, inmutabilidad) es cómo ese estado se comporta cuando lo cambias. La propiedad de que "el inicial solo cuenta la primera vez" es la que explica el bug de la lección 2 (copiar una prop en estado la desincroniza), y la que prepara el snapshot de la lección 4. Y el modelo de la celda de memoria que usamos aquí es el mismo que reaparece en cada ejemplo ejecutado del módulo: entender cómo useState guarda su valor en una celda por posición es entender por qué el estado persiste y por qué el orden de los hooks importa.

Una analogía: el casillero numerado del gimnasio

Piensa en los casilleros de un gimnasio. Cuando entras por primera vez, tomas el casillero número 3, metes tus cosas y le pones un candado. Ese acto —"inicializar"— pasa una sola vez: al entrar. Después, cada vez que vuelves del área de ejercicio, no vuelves a "tomar un casillero vacío"; vas al número 3 y encuentras lo que dejaste. El casillero recuerda su contenido entre tus idas y venidas. Y para cambiar lo que hay dentro, no rompes el candado ni tomas otro casillero: usas tu llave (el setter) para abrir el 3, cambiar el contenido, y cerrarlo.

useState es tomar un casillero. La primera vez, useState('') dice "dame el casillero de esta posición y ponle '' adentro". Las siguientes veces —los renders siguientes—, useState('') dice "dame el casillero de esta posición" y te devuelve lo que ya guardaste, ignorando el '' (ya no estás entrando por primera vez). Por eso el valor inicial solo cuenta una vez: es el contenido con el que estrenas el casillero, no algo que se reponga cada visita.

Hay un detalle de los casilleros que explica una regla real de los hooks: funcionan por número de posición. Tú eres "el del casillero 3" porque siempre tomas el tercero; si un día llegaras y tomaras casilleros en distinto orden, encontrarías las cosas de otra persona. Por eso React exige que llames a tus useState siempre en el mismo orden, en el tope del componente: React los identifica por posición (primero, segundo, tercero), no por nombre. Si escondieras un useState dentro de un if que a veces corre y a veces no, el orden se descuadraría y los casilleros se cruzarían. Guarda la imagen: cada useState es tu casillero numerado, que recuerda su contenido y que abres con tu llave.

Ejemplo trabajado: la celda que se inicializa una vez y el setter que la reescribe

Vamos a ver useState por dentro, ejecutado. Modelamos un mini-runtime de hooks —una versión mínima de lo que React hace internamente— con celdas de memoria indexadas por posición, y observamos las dos propiedades clave: que el valor inicial entra solo la primera vez y que el setter reescribe la misma celda y dispara un render.

Así se escribe en React de verdad, aplicado al query de la SearchBar:

import { useState } from 'react';

function SearchBar() {
  const [query, setQuery] = useState(''); // el casillero de query, arranca ''
  return (
    <input
      className="search-bar"
      value={query}
      // el onChange que llama a setQuery es el modulo 4
    />
  );
}

Y la versión ejecutable en Node. El truco es el mismo del módulo: useState guarda su valor en una celda por posición (cells[i]), y un cursor decide qué celda toca en cada render. Agregamos logs dentro de useState para ver cuándo se inicializa y cuándo se ignora el inicial:

'use strict';

// ── Mini-runtime de hooks (lo que React hace por dentro, en miniatura) ──
// cells: las celdas de estado del componente, una por cada useState.
// cursor: cual celda toca en ESTE render (por eso el orden importa).
let cells = [];
let cursor = 0;
let renderApp = () => {};

function useState(initial) {
  const i = cursor++; // la posicion de ESTE useState
  if (!(i in cells)) {
    cells[i] = initial; // PRIMER render: usa el valor inicial
    console.log(`    [useState] celda ${i}: inicializada con ${JSON.stringify(initial)}`);
  } else {
    console.log(`    [useState] celda ${i}: ya existe (${JSON.stringify(cells[i])}), ignora el inicial`);
  }
  const setState = (next) => {
    cells[i] = next; // guarda el valor nuevo en la MISMA celda
    renderApp(); // y pide un re-render
  };
  return [cells[i], setState]; // devuelve el PAR [valor, setter]
}

// El componente declara su estado con useState.
let ui;
function SearchBar() {
  const [query, setQuery] = useState(''); // 'query' arranca en ''
  console.log(`  render: query = ${JSON.stringify(query)}`);
  return { query, setQuery };
}

function render() {
  cursor = 0; // cada render empieza por la primera celda
  ui = SearchBar();
}
renderApp = render;

console.log('=== render 1 (primer render: useState("") usa el inicial) ===');
render();

console.log('\n=== el usuario escribe: setQuery("mo") ===');
ui.setQuery('mo');

console.log('\n=== sigue escribiendo: setQuery("mouse") ===');
ui.setQuery('mouse');

console.log('\n=== useState devuelve un PAR: [valor, setter] ===');
console.log('  ui.query es un valor:', JSON.stringify(ui.query), '(typeof', typeof ui.query + ')');
console.log('  ui.setQuery es una funcion:', typeof ui.setQuery);

Qué esperar. Al correr el archivo, la salida es exactamente esta:

=== render 1 (primer render: useState("") usa el inicial) ===
    [useState] celda 0: inicializada con ""
  render: query = ""

=== el usuario escribe: setQuery("mo") ===
    [useState] celda 0: ya existe ("mo"), ignora el inicial
  render: query = "mo"

=== sigue escribiendo: setQuery("mouse") ===
    [useState] celda 0: ya existe ("mouse"), ignora el inicial
  render: query = "mouse"

=== useState devuelve un PAR: [valor, setter] ===
  ui.query es un valor: "mouse" (typeof string)
  ui.setQuery es una funcion: function

Esta salida muestra useState sin misterio. Vamos por sus tres lecciones.

El valor inicial, solo la primera vez. Mira los logs de [useState]. En el render 1 dice celda 0: inicializada con "" —tomó el casillero por primera vez y le puso el ''—. Pero en los renders siguientes dice celda 0: ya existe (...), ignora el inicial. React (nuestro mini-runtime) ya no mira el '' que escribiste en useState(''); devuelve lo que hay guardado en la celda. Esto es exactísimamente lo que pasa en React de verdad, y es la razón de que copiar una prop en useState(props.name) la desincronice: el props.name solo se lee en el primer render; si después cambia, tu estado no se entera. El inicial es el estreno del casillero, no su reposición.

El setter reescribe la misma celda y re-renderiza. Fíjate en la secuencia: tras setQuery("mo"), el siguiente render lee query = "mo"; tras setQuery("mouse"), lee "mouse". El setter hizo cells[i] = next (reescribió el casillero 0) y luego renderApp() (pidió un nuevo render). Por eso cada render ve el valor que dejó el setter anterior: la celda persiste entre renders, y el setter es lo que la cambia. Nota que la celda es la misma (celda 0) en todos los renders: useState no crea un casillero nuevo cada vez; siempre vuelve al de esa posición.

useState devuelve un par. La última sección lo comprueba: ui.query es un valor ("mouse", un string) y ui.setQuery es una función. Por eso escribes const [query, setQuery] = useState('') con corchetes: estás desestructurando un array de dos posiciones —el valor en la 0, el setter en la 1—. No es un objeto con nombres fijos; es un par posicional, y por eso eliges los nombres (query/setQuery) al desestructurar.

Por qué se llama "hook" y las dos reglas que impone

useState es un hook: una función especial de React que empieza con use y que "engancha" tu componente al sistema de estado (y de otras capacidades, como los efectos del módulo 6). El modelo del casillero por posición explica las dos reglas que React impone a los hooks, y por qué:

  1. Llama a los hooks siempre en el tope del componente, no dentro de if, bucles o funciones anidadas. React identifica cada useState por su orden de llamada (primer hook, segundo hook...), no por su nombre. Si un useState estuviera dentro de un if que a veces corre y a veces no, el orden cambiaría entre renders y los casilleros se cruzarían: query podría recibir el valor de expanded. Por eso van siempre arriba, en el mismo orden, en cada render.
  2. Llama a los hooks solo desde componentes (o desde otros hooks). No desde funciones normales de JavaScript. Los hooks necesitan el "contexto de render" de React para saber a qué componente pertenecen sus casilleros; fuera de un componente, no hay contexto.

En nuestro modelo, la regla 1 es literal: si cambiaras el orden de los useState, el cursor asignaría celdas equivocadas. En React de verdad es igual, solo que el mecanismo está oculto. Respeta las reglas y no tendrás que pensar en ellas.

Estado inicial costoso: la forma con función

Un detalle práctico que aparece pronto: si calcular el valor inicial es costoso (leer de localStorage, procesar un array grande), no querrás pagarlo en cada render —aunque el inicial se ignore después, el argumento se evalúa cada vez que se llama useState—. Para eso, useState acepta una función inicializadora: useState(() => computeExpensive()). React la llama solo en el primer render; en los demás, ni la ejecuta. Compara:

useState(computeExpensive());       // computeExpensive() corre en CADA render (se descarta luego)
useState(() => computeExpensive()); // computeExpensive() corre SOLO en el primer render

Para valores baratos ('', 0, false) da igual; usa la forma directa. Para cálculos caros, pasa la función. Es la misma celda, solo cambia cuándo se evalúa el argumento.

Errores comunes

Cambiar el estado con una asignación en vez del setter. Qué pasa: se escribe query = 'mo' (o se muta la variable) en lugar de setQuery('mo'), y la pantalla no se actualiza. Por qué pasa: query parece una variable normal, y en JavaScript una variable se cambia asignándole. Cómo detectarlo: modificas la variable de estado sin el setter, y no hay re-render (o React ni se entera). Cómo corregirlo: el estado solo se cambia con su setter. La variable query es de solo lectura dentro del render: refleja lo que hay en la celda, pero para cambiar la celda —y disparar el repintado— usas setQuery. Asignar a query a lo sumo cambia una copia local que se descarta al terminar el render; no toca la libreta de React.

Esperar que el valor inicial se "actualice". Qué pasa: se pone useState(props.value) o useState(algoQueCambia) esperando que el estado siga a ese valor cuando cambie. Por qué pasa: uno lee useState(props.value) como "el estado es props.value", cuando en realidad es "el estado arranca en props.value, una vez". Cómo detectarlo: el estado se queda con el valor del primer render aunque el argumento de useState cambie después. Cómo corregirlo: recuerda que el inicial solo cuenta la primera vez. Si necesitas un valor que siga a una prop, probablemente no era estado sino un valor derivado (lección 2); si de verdad necesitas "resetear" el estado cuando una prop cambia, hay técnicas específicas (como cambiar la key del componente), pero la regla base es: useState(x) usa x una sola vez.

Llamar a useState de forma condicional. Qué pasa: se mete un useState dentro de un if, un bucle, o después de un return temprano. Por qué pasa: parece razonable "solo declarar el estado si hace falta". Cómo detectarlo: React lanza un error del tipo "Rendered fewer/more hooks than expected", o el estado se comporta de forma errática entre renders. Cómo corregirlo: todos los hooks van en el tope del componente, incondicionalmente, en el mismo orden en cada render. Si necesitas lógica condicional, ponla después de declarar todos los hooks, o dentro del valor que calculas, no alrededor de la llamada a useState. React identifica los hooks por posición; saltarte uno descuadra todos los que siguen.

Ejercicios

Ejercicio 1 — Lee la línea. Para const [expanded, setExpanded] = useState(false), responde sin correr nada: (a) ¿qué devuelve useState(false)? (b) ¿qué es expanded y qué es setExpanded? (c) ¿en qué renders se usa el false? (d) ¿cómo cambiarías el estado a true?

Ver solución
  • (a) Devuelve un par (un array de dos posiciones): en la posición 0, el valor actual del estado; en la posición 1, la función para cambiarlo.
  • (b) expanded es el valor actual del estado (un booleano; arranca en false). setExpanded es el setter: la función que reescribe ese estado y dispara un re-render.
  • (c) El false se usa solo en el primer render (al inicializar la celda). En los renders siguientes, useState lo ignora y devuelve lo que haya guardado.
  • (d) Con el setter: setExpanded(true). Nunca con expanded = true (eso no toca la libreta de React ni repinta).

Ejercicio 2 — Encuentra la violación de las reglas. Este componente rompe una regla de los hooks. Identifícala, explica por qué es un problema, y reescríbelo:

function ProductCard(props) {
  if (!props.inStock) {
    return <p>Out of stock</p>;
  }
  const [expanded, setExpanded] = useState(false);
  return <article>{expanded ? 'detalles...' : props.name}</article>;
}
Ver solución

La violación: el useState está después de un return condicional. Cuando props.inStock es false, el componente retorna antes de llegar al useState, así que ese render no llama al hook. Cuando props.inStock es true, sí lo llama. El número de hooks cambia entre renders según la prop.

Por qué es un problema: React identifica los hooks por su orden de llamada. Si un render llama a cero hooks y otro llama a uno, React pierde la cuenta de qué celda es cuál, y lanza un error ("Rendered fewer hooks than expected") o corrompe el estado. Los hooks tienen que llamarse siempre, en el mismo orden, sin importar las props.

Reescrito, con el hook en el tope:

function ProductCard(props) {
  const [expanded, setExpanded] = useState(false); // SIEMPRE se llama, primero
  if (!props.inStock) {
    return <p>Out of stock</p>;
  }
  return <article>{expanded ? 'detalles...' : props.name}</article>;
}

Ahora useState corre en todos los renders (está antes de cualquier return), y el return condicional queda después. El componente puede seguir retornando temprano; lo que no puede es saltarse un hook. La regla: hooks arriba, incondicionales; lógica condicional después.

Ejercicio 3 — El casillero por posición. Un componente declara dos estados: const [query, setQuery] = useState('') y luego const [expanded, setExpanded] = useState(false). Explica, con el modelo del casillero/celda por posición, por qué el orden en que se escriben estas dos líneas no se puede cambiar entre un render y otro.

Ver solución

Con el modelo de celdas: en el primer render, el primer useState toma la celda 0 (y guarda ''), y el segundo toma la celda 1 (y guarda false). React no anota "esta celda es query" por el nombre; la anota por posición: celda 0 = el primer hook, celda 1 = el segundo.

En el segundo render, React vuelve a recorrer los hooks en orden y reparte las celdas por posición otra vez: el primer useState que encuentre → celda 0, el segundo → celda 1. Mientras el orden sea el mismo, query siempre cae en la celda 0 (con su valor '' o el que sea) y expanded en la celda 1.

Pero si en algún render invirtieras el orden —declararas primero expanded y luego query—, entonces el primer useState (ahora el de expanded) tomaría la celda 0, que contenía el valor de query, y viceversa. Los estados se cruzarían: expanded recibiría el texto de búsqueda y query recibiría el booleano. Por eso el orden de los hooks tiene que ser estable entre renders: React los casa con sus celdas por posición, no por nombre. Es exactamente la razón de la regla "llama a los hooks siempre en el mismo orden, en el tope".

Resumen y siguiente paso

En esta lección aprendiste a escribir estado con useState. Leíste la línea const [query, setQuery] = useState('') de derecha a izquierda: useState(inicial) devuelve un par —el valor actual y el setter—, que desestructuras con corchetes eligiendo los nombres (x/setX). Lo mediste ejecutando un mini-runtime de hooks: viste que el valor inicial se usa solo en el primer render (después, useState lo ignora y devuelve lo guardado), que el setter reescribe la misma celda y dispara un re-render, y que el estado vive en una celda por posición —lo que explica las reglas de los hooks (siempre en el tope, mismo orden)—. También viste la forma con función para el inicial costoso.

Antes de avanzar deberías poder: escribir un useState y explicar cada parte (el par, la desestructuración, el inicial); saber por qué el inicial solo cuenta la primera vez; cambiar el estado con el setter (nunca con =); y respetar las reglas de los hooks (arriba, en orden, sin condicionales), explicando por qué con el modelo del casillero por posición.

La lección 4 aborda la propiedad del estado que más desconcierta a quien empieza, y que ya asomó un par de veces: el estado es un snapshot. Vas a medir algo que parece imposible —que setCount(count + 1) dos veces seguidas suma 1, no 2— y a entender exactamente por qué: dentro de un render, la variable de estado es una constante fija, la foto de ese instante. Cuando lo veas ejecutado, el comportamiento "raro" del estado se volverá obvio.

Recursos