Módulo 4: Events And Handlers

Inputs controlados: la `SearchBar`

Descripción

Llegamos al patrón que más vas a repetir en tu vida de React: el input controlado. Es la forma en que React maneja los campos de un formulario —una barra de búsqueda, un campo de nombre, un área de comentarios—, y consiste en una idea de una sola frase: el estado manda sobre el input, no al revés. El texto que se ve en el campo no lo guarda el input por su cuenta; lo guarda el estado del componente, y el input solo lo muestra. Esta lección construye la SearchBar de Mercado con ese patrón, junta todo lo del módulo hasta aquí (eventos, pasar la función, e.target.value) en un bucle cerrado, y lo ejecuta letra por letra para que veas el estado y el input moverse juntos.

Conexión con el módulo. Es la síntesis de las lecciones 2, 3 y 4 aplicada a un input real. Usa un handler (L2), pasado sin llamarlo (L3), que lee e.target.value (L4) para llamar al setter setQuery (el estado del módulo 3). El resultado es el query de la SearchBar, que en la lección 6 subirá al App por un callback, y que en el módulo 5 servirá para derivar la lista filtrada. Si el input controlado te queda claro, la mitad de los formularios de React ya no tienen misterio.

Una analogía: el termostato de la casa

Piensa en un termostato con una pantalla que muestra la temperatura deseada, digamos 21°. Hay dos formas de diseñarlo. En la mala, la pantalla tiene su propia memoria: giras la perilla, la pantalla se acuerda del número por su cuenta, y el sistema de calefacción, aparte, tiene otra copia del número. Dos memorias del mismo dato: tarde o temprano se desincronizan, y la pantalla dice 21° mientras la calefacción cree que son 19°. En la buena, hay una sola memoria —la del sistema— y la pantalla solo la refleja: la pantalla no "recuerda" nada, siempre muestra lo que dice el sistema. Cuando giras la perilla, no cambias la pantalla; le avisas al sistema "quiero 22°", el sistema actualiza su única memoria, y la pantalla —que refleja esa memoria— pasa a mostrar 22°.

Un input controlado es el termostato bueno. La única memoria del texto es el estado (query). El <input> es la pantalla: no guarda el texto por su cuenta, solo muestra lo que dice el estado (value={query}). Y cuando el usuario teclea, no cambia el input directamente; avisa al estado (onChange={e => setQuery(e.target.value)}), el estado actualiza su única memoria, React re-renderiza, y el input —que refleja el estado— pasa a mostrar el texto nuevo. Una sola fuente de verdad, el estado, y el input a su servicio. Un input no controlado sería el termostato malo: el <input> guarda su propio texto (como en el HTML de siempre), y tú tienes que ir a preguntárselo cuando lo necesites, arriesgándote a que tu estado y el input digan cosas distintas.

Ejemplo trabajado: el bucle del input controlado, letra por letra

Vamos a construir la SearchBar controlada y a verla funcionar tecla por tecla. Primero, el componente tal como lo escribirás en React:

function SearchBar() {
  const [query, setQuery] = useState('');
  return (
    <input
      value={query}                              // el valor SALE del estado
      onChange={(e) => setQuery(e.target.value)} // el estado SIGUE al teclado
      placeholder="Search products..."
    />
  );
}

Léelo como el termostato. value={query}: el input muestra lo que dice el estado —es la pantalla reflejando la única memoria—. onChange={(e) => setQuery(e.target.value)}: cuando el usuario teclea, el handler lee el texto propuesto (e.target.value, de la lección 4) y le avisa al estado (setQuery, del módulo 3). Fíjate en dos cosas que ya sabes: a onChange le pasamos una función (la flecha), no la llamamos (lección 3); y sacamos el valor de e.target.value (lección 4). Todo el módulo confluye en estas dos líneas.

Ahora ejecutémoslo. Reusamos el mini-runtime de estado del módulo 3 (una celda que persiste entre renders). Modelamos una tecla como lo que es: el navegador propone un nuevo valor (el actual más la letra) y React llama a onChange con un evento { target: { value } }. Cada render imprime qué value muestra el input, para verlo seguir al estado:

// La celda de estado del componente (como en el modulo 3).
let cell;
let firstRender = true;
function useState(initial) {
  if (firstRender) cell = initial;
  return [cell, (next) => { cell = next; render(); }];
}
function h(tag, props, ...children) {
  return { tag, props: props || {}, children: children.flat() };
}
let tree;
// La SearchBar CONTROLADA: el value SALE del estado; onChange lo ACTUALIZA.
function SearchBar() {
  const [query, setQuery] = useState('');
  return h('input', {
    value: query,                              // el valor viene del estado
    onChange: (e) => setQuery(e.target.value), // el estado sigue al teclado
  });
}
function render() {
  tree = SearchBar();
  firstRender = false;
  console.log(`  render -> <input value="${tree.props.value}">`);
}
// Modela una tecla: el navegador propone value = valor_actual + letra,
// React arma el evento { target: { value } } y llama a onChange.
function typeKey(letter) {
  const proposed = tree.props.value + letter;
  console.log(`tecla '${letter}' -> onChange({ target: { value: "${proposed}" } })`);
  tree.props.onChange({ target: { value: proposed } });
}
console.log('=== Input controlado: el estado es la unica fuente de verdad ===\n');
render(); // render inicial: value=""
for (const letter of 'mouse') typeKey(letter);
console.log(`\nEstado final: query = "${cell}"`);

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

=== Input controlado: el estado es la unica fuente de verdad ===

  render -> <input value="">
tecla 'm' -> onChange({ target: { value: "m" } })
  render -> <input value="m">
tecla 'o' -> onChange({ target: { value: "mo" } })
  render -> <input value="mo">
tecla 'u' -> onChange({ target: { value: "mou" } })
  render -> <input value="mou">
tecla 's' -> onChange({ target: { value: "mous" } })
  render -> <input value="mous">
tecla 'e' -> onChange({ target: { value: "mouse" } })
  render -> <input value="mouse">

Estado final: query = "mouse"

Lee la salida como el bucle que es, porque cada tecla da una vuelta completa.

El render inicial muestra <input value="">: el estado empieza en '' (el valor inicial de useState('')), y el input refleja ese vacío. Luego, la tecla 'm'. Mira la vuelta completa: el navegador propone value = "" + "m" = "m" y React llama a onChange({ target: { value: "m" } }); el handler lee e.target.value ("m") y llama a setQuery("m"); el estado pasa a "m" y dispara un re-render; y el nuevo render muestra <input value="m">. El input ahora muestra "m" no porque el input se lo guardara, sino porque el estado cambió y el input lo refleja. La tecla 'o' repite la vuelta: "m" + "o" = "mo", y el render muestra value="mo". Y así hasta "mouse".

Detente en la dirección del flujo, porque es la tesis del patrón. El texto nunca vive en el input. Vive en el estado. En cada tecla, el estado se actualiza primero y el input lo sigue después. Por eso el Estado final es query = "mouse": si en cualquier momento quisieras saber qué escribió el usuario, no le preguntas al input —le preguntas al estado, que es la única fuente de verdad—. Ese es el termostato bueno: una sola memoria, y la pantalla a su servicio.

Profundización: controlado, no controlado, y la trampa del value sin onChange

Las dos piezas obligatorias. Un input controlado necesita las dos: value={estado} (para que muestre el estado) y onChange={...que actualiza el estado...} (para que el estado siga al usuario). Si tienes solo onChange pero no value, el input guarda su propio texto (no controlado). Si tienes solo value pero no onChange, caes en la trampa que vemos abajo. El patrón completo es el ciclo value → onChange → setState → re-render → value.

La trampa: value sin onChange congela el input. Si pones value={query} pero olvidas el onChange, el input queda de solo lectura: el usuario teclea, pero el estado nunca cambia, así que en el siguiente render React vuelve a poner el value viejo, y el campo parece congelado. Veámoslo:

// TRAMPA: value SIN onChange. El value queda clavado al estado, que nunca cambia.
const query = 'mouse'; // el estado, que aqui NUNCA se actualiza
function h(tag, props, ...children) {
  return { tag, props: props || {}, children: children.flat() };
}
const input = h('input', { value: query }); // sin onChange
console.log('input value:', JSON.stringify(input.props.value));
console.log('onChange:', input.props.onChange);
console.log('El usuario teclea "x"... pero no hay onChange que actualice el estado.');
console.log('React vuelve a poner value =', JSON.stringify(query), '-> el input parece congelado.');

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

input value: "mouse"
onChange: undefined
El usuario teclea "x"... pero no hay onChange que actualice el estado.
React vuelve a poner value = "mouse" -> el input parece congelado.

El input tiene value = "mouse" pero onChange = undefined: no hay quién actualice el estado. El usuario puede teclear "x", pero como nada llama a setQuery, el estado sigue en "mouse", y React —que hace que el input refleje el estado— vuelve a poner "mouse" en el campo. El resultado es un input que no deja escribir. React de hecho te advierte de esto en la consola: "You provided a value prop to a form field without an onChange handler.". La regla: si pones value, pon también onChange.

Controlado vs no controlado, cuándo cada uno. Un input no controlado no lleva value del estado; el DOM guarda el texto por su cuenta, y tú lo lees cuando lo necesitas (con una ref, tema de guías posteriores). Es más simple para formularios de "dispara y olvida" (un campo que solo lees al enviar). Un input controlado —el que enseñamos— mantiene el estado sincronizado en todo momento, y por eso es el que necesitas cuando otra parte de la UI depende de lo que se escribe en tiempo real: y ese es justo el caso de Mercado, porque la lista de productos se filtra mientras el usuario escribe. Para que el ProductList reaccione a cada tecla del query, el query tiene que estar en el estado; es decir, el input tiene que ser controlado. Regla práctica de React: por defecto, usa inputs controlados.

El valor inicial y el input vacío. useState('') arranca el query en string vacío, y por eso el input empieza vacío. Nunca uses useState() sin argumento para un input controlado: el estado sería undefined, y un value={undefined} hace que React trate el input como no controlado (y vuelva a la advertencia de arriba). Para un campo de texto, el inicial correcto es ''.

Errores comunes

value sin onChange (el input congelado). Qué pasa: el campo no deja escribir; tecleas y no aparece nada. Por qué pasa: se puso value={query} para "mostrar el estado" pero se olvidó el onChange que lo actualiza. Cómo detectarlo: el input está muerto, y la consola muestra la advertencia de React sobre value sin onChange. Cómo corregirlo: agrega el handler que llama al setter: onChange={(e) => setQuery(e.target.value)}. Las dos piezas van juntas, siempre.

onChange sin value (input no controlado sin querer). Qué pasa: el input sí deja escribir, y hasta actualizas el estado, pero el estado y el input pueden divergir —por ejemplo, si "limpias" el estado con setQuery(''), el campo no se vacía—. Por qué pasa: se puso el onChange pero se olvidó value={query}, así que el input guarda su propio texto en paralelo al estado. Cómo detectarlo: cambias el estado por código (un botón "limpiar") y el input no reacciona. Cómo corregirlo: agrega value={query} para que el input refleje el estado y no tenga memoria propia. Con las dos piezas, el estado manda.

Inicializar el estado del input en undefined. Qué pasa: aparece la advertencia "changing an uncontrolled input to be controlled" cuando el usuario empieza a escribir. Por qué pasa: useState() (sin argumento) deja el estado en undefined, y value={undefined} es un input no controlado; al teclear y llamar a setQuery("m") pasa a controlado, y React se queja del cambio de régimen. Cómo detectarlo: la advertencia justo al escribir la primera letra. Cómo corregirlo: inicializa con string vacío —useState('')— para que sea controlado desde el primer render.

Ejercicios

Ejercicio 1 — Completa el input controlado. Este input está a medias. Complétalo para que sea un input controlado sobre el estado query:

function SearchBar() {
  const [query, setQuery] = useState('');
  return <input /* ??? */ />;
}
Ver solución

Un input controlado necesita las dos piezas: value que sale del estado, y onChange que lo actualiza.

function SearchBar() {
  const [query, setQuery] = useState('');
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}
  • value={query}: el input muestra lo que dice el estado (la pantalla del termostato).
  • onChange={(e) => setQuery(e.target.value)}: cada tecla lee el texto propuesto (e.target.value) y avisa al estado (setQuery). Nota que a onChange le pasas una función (la flecha), no la llamas.

Con las dos, el estado es la única fuente de verdad y el input lo refleja.

Ejercicio 2 — ¿Por qué no deja escribir? Un compañero dice que su input "no funciona: tecleo y no aparece nada". Su código es:

function SearchBar() {
  const [query, setQuery] = useState('');
  return <input value={query} />;
}

Diagnostica el problema y arréglalo.

Ver solución

El problema es value sin onChange. El input muestra query (que es '' y nunca cambia), pero no hay ningún handler que actualice el estado cuando el usuario teclea. Entonces: el usuario teclea, nada llama a setQuery, el estado sigue en '', y React —que hace que el input refleje el estado— vuelve a poner '' en el campo. El input queda de solo lectura. React lo advierte en la consola: "You provided a value prop to a form field without an onChange handler."

El arreglo es agregar el onChange:

function SearchBar() {
  const [query, setQuery] = useState('');
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

Ahora cada tecla actualiza el estado, y el input, al reflejar el estado, muestra lo que se escribe.

Ejercicio 3 — Traza el bucle. Partiendo de query = "mo", el usuario teclea la letra 'u'. Escribe, en orden, los cuatro pasos del bucle del input controlado (qué valor propone el navegador, qué recibe onChange, qué hace el setter, qué muestra el nuevo render).

Ver solución

Partiendo de query = "mo" y tecleando 'u':

  1. El navegador propone el nuevo valor: el actual más la letra, "mo" + "u" = "mou". React arma un evento con target.value = "mou".
  2. onChange recibe ese evento y ejecuta (e) => setQuery(e.target.value): lee e.target.value ("mou") y llama a setQuery("mou").
  3. El setter cambia el estado de "mo" a "mou" y dispara un re-render.
  4. El nuevo render ejecuta SearchBar con query = "mou", y devuelve <input value="mou">: el input muestra "mou" porque refleja el estado.

En la salida del ejemplo trabajado, esto es exactamente el par de líneas:

tecla 'u' -> onChange({ target: { value: "mou" } })
  render -> <input value="mou">

El texto nunca lo guardó el input: el estado se actualizó primero, y el input lo siguió.

Resumen y siguiente paso

En esta lección construiste el patrón que más usarás en React: el input controlado. Su regla es una sola: el estado es la única fuente de verdad, y el input lo refleja —como el termostato bueno, con una sola memoria y la pantalla a su servicio—. Se arma con dos piezas obligatorias: value={query} (el input muestra el estado) y onChange={(e) => setQuery(e.target.value)} (cada tecla avisa al estado). Lo mediste letra por letra: al teclear "mouse", el estado avanzó "" → "m" → "mo" → ... → "mouse" y el value del input siguió al estado en cada render, nunca al revés. Y viste la trampa: value sin onChange congela el input, porque el estado nunca cambia y React vuelve a poner el valor viejo. Esta lección juntó todo el módulo: un handler (L2), pasado sin llamarlo (L3), que lee e.target.value (L4) y llama a un setter (M3).

Antes de avanzar deberías poder: explicar por qué el estado —y no el input— guarda el texto en un input controlado; escribir un input controlado con sus dos piezas; diagnosticar un input congelado (value sin onChange); y decir por qué Mercado necesita un input controlado (la lista se filtra en vivo con el query).

La lección 6 responde la pregunta que quedó colgando: el query que la SearchBar guarda, y el product que un ProductCard quiere agregar, tienen que llegar al App —que está más arriba en el árbol—. ¿Cómo sube un dato de un hijo a un padre? Con callbacks: el padre le da al hijo una función por props, el hijo la invoca con su dato, y el padre decide. Los datos bajan por props; los datos suben por llamadas a función. Ahí completamos la simetría del módulo.

Recursos