Una lista muestra un curso, pero encima lees «2 resultados». Cambias otro filtro y el contador vuelve a coincidir. Este fallo pequeño sirve para aprender una decisión importante en React: qué información guardar en estado y qué información calcular a partir de ella.
Trabajaremos con cuatro cursos ficticios, una búsqueda por título y un checkbox de nivel básico. No son datos del catálogo real ni hay precios. La regla que vamos a comprobar es sencilla: el contador debe ser siempre la longitud de la lista visible.
Imagen ilustrativa generada con IA; no es una captura de la aplicación.
1. Prepara el catálogo y el filtro
Guarda este módulo como course-filter.mjs. La búsqueda ignora mayúsculas y espacios en los extremos. No elimina tildes; buscar sin acentos es una ampliación distinta del ejercicio.
// Original teaching fixture. This is a fictional catalog, not live course data.
export const COURSES = [
{ id: "c1", title: "IA para imágenes", level: "basico" },
{ id: "c2", title: "IA para trabajo", level: "intermedio" },
{ id: "c3", title: "React", level: "basico" },
{ id: "c4", title: "SQL", level: "basico" },
];
export function filterCourses(courses, query, onlyBasic) {
const term = query.trim().toLocaleLowerCase("es");
return courses.filter(course =>
course.title.toLocaleLowerCase("es").includes(term) &&
(!onlyBasic || course.level === "basico")
);
}
Para probar los componentes necesitas un proyecto React existente que compile JSX. Coloca el módulo junto a ambos componentes y monta primero la versión rota como componente principal. El checker de JavaScript que verás más abajo funciona con Node sin instalar React.
2. Reproduce el desacuerdo
Guarda esta versión deliberadamente incorrecta como CourseFilter.buggy.jsx:
import { useEffect, useState } from "react";
import { COURSES, filterCourses } from "./course-filter.mjs";
export default function CourseFilterBuggy() {
const [query, setQuery] = useState("");
const [onlyBasic, setOnlyBasic] = useState(false);
const [count, setCount] = useState(COURSES.length);
const visible = filterCourses(COURSES, query, onlyBasic);
// Deliberate teaching bug: changing onlyBasic does not refresh the counter.
useEffect(() => {
setCount(filterCourses(COURSES, query, onlyBasic).length);
}, [query]);
return (
<section aria-labelledby="buggy-title">
<h1 id="buggy-title">Catálogo ficticio con un fallo</h1>
<label>
Buscar por título
<input value={query} onChange={event => setQuery(event.target.value)} />
</label>
<label>
<input
type="checkbox"
checked={onlyBasic}
onChange={event => setOnlyBasic(event.target.checked)}
/>
Solo nivel básico
</label>
<button type="button" onClick={() => { setQuery(""); setOnlyBasic(false); }}>
Limpiar filtros
</button>
<p role="status">{count} resultados</p>
{visible.length === 0 ? <p>No hay cursos que coincidan.</p> : (
<ul>{visible.map(course => <li key={course.id}>{course.title}</li>)}</ul>
)}
</section>
);
}
Escribe ia y espera a que aparezcan dos cursos y «2 resultados». Activa «Solo nivel básico» sin cambiar el texto. La lista queda con «IA para imágenes», pero el contador continúa en dos.
La lista se calcula con query y onlyBasic. En cambio, el efecto que actualiza count solo vuelve a ejecutarse cuando cambia query. Si tu linter avisa de la dependencia que falta, el aviso es pertinente: no lo desactives para esconder este fallo.
Agregar onlyBasic a las dependencias resolvería este olvido. Antes de hacerlo, pregúntate si necesitas guardar el contador. El usuario elige búsqueda y nivel; no elige un contador independiente. Guardarlo obliga a mantener dos representaciones de la misma selección. La guía oficial de estructura del estado de React explica por qué conviene evitar información redundante que pueda descoordinarse.
3. Calcula la selección durante el render
Conserva únicamente query y onlyBasic como estado. Calcula visible con esos valores y muestra visible.length. Sustituye el componente principal por CourseFilter.fixed.jsx:
import { useState } from "react";
import { COURSES, filterCourses } from "./course-filter.mjs";
export default function CourseFilter() {
const [query, setQuery] = useState("");
const [onlyBasic, setOnlyBasic] = useState(false);
const visible = filterCourses(COURSES, query, onlyBasic);
function clearFilters() {
setQuery("");
setOnlyBasic(false);
}
return (
<section aria-labelledby="catalog-title">
<h1 id="catalog-title">Catálogo ficticio de práctica</h1>
<label>
Buscar por título
<input value={query} onChange={event => setQuery(event.target.value)} />
</label>
<label>
<input
type="checkbox"
checked={onlyBasic}
onChange={event => setOnlyBasic(event.target.checked)}
/>
Solo nivel básico
</label>
<button type="button" onClick={clearFilters}>Limpiar filtros</button>
<p role="status">{visible.length} resultados</p>
{visible.length === 0 ? <p>No hay cursos que coincidan.</p> : (
<ul>
{visible.map(course => <li key={course.id}>{course.title}</li>)}
</ul>
)}
</section>
);
}
Cada render obtiene la lista y su contador del mismo cálculo. «Limpiar filtros» restablece las dos entradas; las claves usan el ID estable del curso y hay un mensaje cuando no queda ningún resultado.
Los campos son controlados: el texto usa value y la casilla usa checked, con sus respectivos manejadores onChange. Puedes contrastar ese patrón con la referencia oficial de input y la documentación de useState.
Aquí no sincronizamos un servidor, almacenamiento ni un widget externo. Por eso este cálculo no requiere un efecto. La documentación You Might Not Need an Effect distingue esa transformación de datos de la sincronización externa, donde los efectos sí pueden ser apropiados. Tampoco añadimos useMemo: cuatro registros no justifican una optimización sin medir un problema.
4. Comprueba seis resultados
| Búsqueda | Solo básico | IDs visibles | Contador |
|---|---|---|---|
| Vacía | No | c1, c2, c3, c4 | 4 |
ia | No | c1, c2 | 2 |
ia | Sí | c1 | 1 |
IA | Sí | c1 | 1 |
zz | Sí | Ninguno | 0 |
| Limpiar ambos filtros | No | c1, c2, c3, c4 | 4 |
Guarda este archivo como check-course-filter.mjs, junto al módulo:
import assert from "node:assert/strict";
import { COURSES, filterCourses } from "./course-filter.mjs";
const cases = [
{ query: "", onlyBasic: false, ids: ["c1", "c2", "c3", "c4"] },
{ query: "ia", onlyBasic: false, ids: ["c1", "c2"] },
{ query: "ia", onlyBasic: true, ids: ["c1"] },
{ query: " IA ", onlyBasic: true, ids: ["c1"] },
{ query: "zz", onlyBasic: true, ids: [] },
{ query: "", onlyBasic: false, ids: ["c1", "c2", "c3", "c4"] },
];
for (const c of cases) {
const visible = filterCourses(COURSES, c.query, c.onlyBasic);
assert.deepEqual(visible.map(x => x.id), c.ids);
assert.equal(visible.length, c.ids.length);
}
// Deterministic reproduction of the stale-counter transition, not a React DOM test:
// The buggy effect runs for query="ia", then the checkbox changes without query.
const storedCount = filterCourses(COURSES, "ia", false).length;
const nextVisible = filterCourses(COURSES, "ia", true);
assert.equal(storedCount, 2);
assert.equal(nextVisible.length, 1);
assert.notEqual(storedCount, nextVisible.length);
console.log(JSON.stringify({
cases: cases.map(c => ({ query: c.query, onlyBasic: c.onlyBasic,
ids: c.ids, count: c.ids.length })),
reproduced_bug: { stale_counter: storedCount, visible_cards: nextVisible.length },
assertions: "PASS",
scope: "Pure filter and deterministic stale-state transition; no browser/React rendering test"
}, null, 2));
Ejecuta:
node check-course-filter.mjs
Una ejecución correcta imprime assertions: "PASS". El checker verifica el filtro y reproduce matemáticamente la transición que deja el contador en dos frente a una tarjeta. Si modificas el catálogo, revisa también los valores esperados: una prueba debe fallar cuando cambia el comportamiento que pretende comprobar.
Qué comprobamos para este artículo: ejecutamos los seis casos de JavaScript en Node 20.14.0. Además compilamos ambos JSX con las dependencias ya instaladas y los montamos en un navegador aislado con React 19.2.4. La versión rota reprodujo una tarjeta y «2 resultados»; la corregida pasó los seis recorridos, incluido limpiar los campos y mostrar el estado vacío, sin errores de JavaScript observados. Son pruebas de este ejemplo local: no certifican otra aplicación ni constituyen una auditoría con lector de pantalla.
Si quieres entender por qué este cambio funciona, continúa con estado y efectos en React Fundamentals. Su programa público incluye esos temas; este ejemplo es un ejercicio original del artículo.
5. Pide una revisión a la IA sin delegar la comprobación
Copia el siguiente prompt y adjunta el módulo, la versión rota y el checker completos:
Revisa estos archivos de un catálogo ficticio de cuatro cursos.
El contador debe ser siempre la longitud exacta de la lista visible.
Las entradas son texto de búsqueda y un checkbox de nivel básico.
Primero identifica qué variables deben ser estado y cuáles se pueden derivar.
Reproduce query='ia', básico=false; después básico=true sin cambiar query.
Explica qué lista y contador produce el código roto.
Propón el cambio mínimo sin guardar otra copia del contador ni de la lista.
Mantén labels, keys, botón Limpiar y mensaje vacío.
No añadas librerías, fetch, useMemo ni optimizaciones sin una medición.
Devuelve el componente corregido y las expectativas de los seis casos.
Contrasta las fuentes oficiales enlazadas: no asumas que los efectos
son innecesarios en todas las situaciones.
Este prompt es una plantilla de revisión; no lo ejecutamos en un modelo ni atribuimos el arreglo a una IA. Ejecuta el checker sobre la respuesta que recibas y repite el recorrido en el navegador. Para separar observaciones, propuestas y pruebas al revisar otros programas, sigue también la guía de prompts para revisar código con IA.
La siguiente ampliación útil es agregar una categoría al catálogo. Antes de programarla, escribe los resultados esperados para dos filtros simultáneos y comprueba que la misma regla sigue funcionando: lista y contador deben describir una sola selección.
