Saltar al contenido principal
Logo FPCode

React avanzado y arquitectura de componentes

Contenido

Unidad didáctica avanzada de React para Desarrollo web en entorno cliente: arquitectura de componentes, estado compartido, useReducer, useEffect, hooks personalizados, contexto, refs, datos remotos, accesibilidad, rendimiento y proyecto guiado.

Introducción

En la unidad anterior aprendiste los fundamentos de React: componentes, JSX, props, estado, eventos, listas y formularios controlados. Con esos conceptos ya puedes construir interfaces pequeñas. Sin embargo, cuando una aplicación crece aparecen nuevas preguntas: dónde debe vivir el estado, cómo evitar componentes demasiado grandes, cuándo usar efectos, cómo reutilizar lógica, cómo compartir datos entre componentes lejanos y cómo organizar el proyecto para que siga siendo mantenible.

Esta unidad profundiza en React desde una perspectiva práctica. No busca introducir muchas librerías ni patrones complejos, sino ayudarte a tomar decisiones razonables en aplicaciones medianas. El objetivo es que sepas estructurar componentes, sincronizar la interfaz con APIs o sistemas externos, extraer hooks personalizados y preparar un proyecto más sólido sin perder claridad.

React avanzado no significa escribir código más complicado. Significa saber cuándo una solución simple deja de ser suficiente y qué herramienta de React conviene usar en cada caso.

Conocimiento previo

Referencias

Índice

  1. Qué cambia al pasar de React básico a React avanzado
  2. Diseñar componentes con responsabilidad clara
  3. Estado local, estado compartido y estado derivado
  4. Actualizar estructuras de estado sin mutarlas
  5. useReducer para lógica de estado más compleja
  6. useEffect como sincronización con sistemas externos
  7. Cuándo no necesitas un efecto
  8. Peticiones remotas con estados de carga y error
  9. Hooks personalizados para reutilizar lógica
  10. Contexto para datos compartidos entre componentes lejanos
  11. Refs para acceder a valores o elementos externos al renderizado
  12. Formularios con validación y mensajes accesibles
  13. Organización de carpetas en una aplicación React
  14. Introducción a rutas en aplicaciones cliente
  15. Accesibilidad en componentes interactivos
  16. Rendimiento sin sobreoptimizar
  17. TypeScript en componentes y hooks
  18. Proyecto guiado: catálogo con filtros, favoritos y datos remotos 18.1. Definir la estructura del proyecto 18.2. Crear un servicio de datos 18.3. Crear un hook para cargar productos 18.4. Gestionar filtros con useReducer 18.5. Compartir favoritos con contexto 18.6. Componer la pantalla principal
  19. Criterios para elegir una solución
  20. Ejercicios

1. Qué cambia al pasar de React básico a React avanzado

En React básico normalmente trabajas con pocos componentes y con estado local sencillo. Un contador, una lista o un formulario pueden resolverse con useState y props directas. En una aplicación más grande, el problema ya no es solo conseguir que algo funcione, sino mantener el código comprensible cuando aparecen más pantallas, más datos y más interacciones.

Los cambios principales son estos:

React básicoReact avanzado
Componentes pequeños con pocos datos.Árbol de componentes con responsabilidades repartidas.
Estado local con useState.Estado local, compartido, derivado y lógica con useReducer.
Props directas entre padre e hijo.Props, composición y contexto cuando hay componentes lejanos.
Formularios simples.Validación, mensajes accesibles y control de envío.
Peticiones puntuales.Estados de carga, error, cancelación lógica y hooks de datos.
Organización informal.Separación de componentes, hooks, servicios y utilidades.

La idea clave es que React no te obliga a usar siempre la misma solución. Puedes empezar con useState y props. Si la lógica crece, puedes extraer componentes, crear un hook personalizado, usar useReducer o compartir datos con contexto.

2. Diseñar componentes con responsabilidad clara

Un componente tiene una responsabilidad clara cuando puedes explicar su trabajo en una frase. Por ejemplo: ListaProductos muestra una colección de productos, FiltroCategoria permite elegir una categoría y ProductoCard representa un producto individual.

Un síntoma de mala organización es un componente App enorme que carga datos, guarda todos los estados, valida formularios, filtra listas, renderiza tarjetas y muestra mensajes. Ese componente puede funcionar, pero será difícil de leer y de modificar.

Ejemplo poco mantenible:

function App() {
const [productos, setProductos] = useState([]);
const [busqueda, setBusqueda] = useState('');
const [categoria, setCategoria] = useState('todas');
const [favoritos, setFavoritos] = useState([]);
const [cargando, setCargando] = useState(false);
const [error, setError] = useState(null);
// Carga datos, filtra, valida, renderiza formularios y tarjetas...
}

Una organización más clara separa responsabilidades:

function CatalogoPage() {
const { productos, cargando, error } = useProductos();
const [filtros, dispatch] = useReducer(filtrosReducer, filtrosIniciales);
const productosFiltrados = filtrarProductos(productos, filtros);
return (
<main>
<CabeceraCatalogo total={productosFiltrados.length} />
<PanelFiltros filtros={filtros} dispatch={dispatch} />
<EstadoCarga cargando={cargando} error={error} />
<ListaProductos productos={productosFiltrados} />
</main>
);
}

No se trata de dividir por dividir. Un componente nuevo aporta valor cuando:

  • Reduce una parte del código que estaba creciendo demasiado.
  • Tiene una responsabilidad que puede nombrarse con claridad.
  • Se reutiliza o puede probarse mentalmente de forma aislada.
  • Evita mezclar lógica de datos con representación visual.
  • Hace más fácil localizar dónde se produce un cambio.

3. Estado local, estado compartido y estado derivado

Antes de elegir una herramienta, conviene identificar qué tipo de estado tienes.

Tipo de estadoQué significaEjemplo
Estado localSolo lo necesita un componente.Si un menú está abierto o cerrado.
Estado compartidoLo necesitan varios componentes.Lista de productos o usuario actual.
Estado derivadoSe calcula a partir de otros datos.Productos filtrados según búsqueda.
Estado remotoProcede de una API o sistema externo.Pedidos descargados desde servidor.

El estado local debe vivir cerca del componente que lo usa. Si solo un componente necesita saber si un panel está desplegado, no tiene sentido subir ese estado a toda la aplicación.

function PanelAyuda() {
const [abierto, setAbierto] = useState(false);
return (
<section>
<button onClick={() => setAbierto(!abierto)}>Ayuda</button>
{abierto && <p>Introduce un nombre y un precio válido.</p>}
</section>
);
}

El estado compartido debe subir al padre común más cercano. Si dos componentes hermanos necesitan leer o modificar el mismo dato, ese dato no debe duplicarse en ambos.

function ProductosPage() {
const [busqueda, setBusqueda] = useState('');
return (
<>
<Buscador value={busqueda} onChange={setBusqueda} />
<ResumenBusqueda busqueda={busqueda} />
</>
);
}

El estado derivado no suele guardarse con useState. Si puedes calcularlo durante el renderizado a partir de datos existentes, calcúlalo directamente.

function ListaProductos({ productos, busqueda }) {
const productosFiltrados = productos.filter((producto) =>
producto.nombre.toLowerCase().includes(busqueda.toLowerCase()),
);
return (
<ul>
{productosFiltrados.map((producto) => (
<li key={producto.id}>{producto.nombre}</li>
))}
</ul>
);
}

Guardar productosFiltrados en otro estado puede generar incoherencias: tendrías dos fuentes de verdad, la lista original y la lista filtrada.

4. Actualizar estructuras de estado sin mutarlas

React espera que trates el estado como inmutable. Esto significa que no debes modificar directamente arrays u objetos guardados en estado. En su lugar, crea una nueva versión con los cambios.

No hagas esto:

function marcarFavorito(id) {
producto.favorito = true;
setProducto(producto);
}

Sí haz esto:

function marcarFavorito(id) {
setProductos((productosActuales) =>
productosActuales.map((producto) =>
producto.id === id
? { ...producto, favorito: true }
: producto,
),
);
}

Patrones habituales:

OperaciónPatrón recomendado
Añadir elemento[...lista, nuevoElemento]
Eliminar elementolista.filter((item) => item.id !== id)
Modificar elementolista.map((item) => item.id === id ? actualizado : item)
Copiar objeto{ ...objeto, propiedad: nuevoValor }

Estos patrones ayudan a React a detectar que el estado ha cambiado y hacen que tu código sea más predecible.

5. useReducer para lógica de estado más compleja

useState funciona bien cuando el estado es simple. Pero si una pantalla tiene muchas acciones relacionadas, varios campos o reglas de actualización, useReducer puede hacer el código más claro.

Un reducer es una función que recibe el estado actual y una acción, y devuelve el siguiente estado.

const estadoInicial = {
busqueda: '',
categoria: 'todas',
soloFavoritos: false,
};
function filtrosReducer(estado, accion) {
if (accion.type === 'cambiar-busqueda') {
return { ...estado, busqueda: accion.valor };
}
if (accion.type === 'cambiar-categoria') {
return { ...estado, categoria: accion.valor };
}
if (accion.type === 'alternar-favoritos') {
return { ...estado, soloFavoritos: !estado.soloFavoritos };
}
return estado;
}

Uso en un componente:

function PanelFiltros() {
const [filtros, dispatch] = useReducer(filtrosReducer, estadoInicial);
return (
<form>
<label htmlFor="busqueda">Buscar</label>
<input
id="busqueda"
value={filtros.busqueda}
onChange={(event) =>
dispatch({ type: 'cambiar-busqueda', valor: event.target.value })
}
/>
<label htmlFor="categoria">Categoría</label>
<select
id="categoria"
value={filtros.categoria}
onChange={(event) =>
dispatch({ type: 'cambiar-categoria', valor: event.target.value })
}
>
<option value="todas">Todas</option>
<option value="hardware">Hardware</option>
<option value="software">Software</option>
</select>
<button
type="button"
onClick={() => dispatch({ type: 'alternar-favoritos' })}
>
Mostrar favoritos
</button>
</form>
);
}

useReducer resulta útil cuando:

  • Hay varias formas de actualizar el mismo estado.
  • Las actualizaciones tienen nombres claros: añadir, eliminar, alternar, reiniciar.
  • El estado tiene varias propiedades relacionadas.
  • Quieres separar la lógica de actualización del JSX.

No es obligatorio usar useReducer en todos los formularios. Para uno o dos campos, useState suele ser suficiente.

6. useEffect como sincronización con sistemas externos

useEffect permite sincronizar un componente con algo que está fuera de React. Ese sistema externo puede ser una API, el almacenamiento del navegador, un temporizador, una suscripción, el título del documento o una biblioteca no controlada por React.

Ejemplo: sincronizar el título del documento con el número de resultados.

useEffect(() => {
document.title = `${total} productos encontrados`;
}, [total]);

La estructura básica de un efecto es:

useEffect(() => {
// Código de sincronización
return () => {
// Limpieza opcional
};
}, [dependencias]);

Las dependencias indican cuándo debe repetirse el efecto. Si el efecto usa total, userId o url, esos valores deben aparecer en el array de dependencias.

Ejemplo con temporizador y limpieza:

useEffect(() => {
const id = setInterval(() => {
console.log('Actualizando reloj');
}, 1000);
return () => {
clearInterval(id);
};
}, []);

La función de limpieza evita que el temporizador siga activo cuando el componente desaparece.

7. Cuándo no necesitas un efecto

Una parte importante de React avanzado consiste en no usar useEffect cuando no hace falta. Muchos efectos innecesarios aparecen porque se guarda como estado algo que podría calcularse directamente.

No es recomendable hacer esto:

function Lista({ productos, busqueda }) {
const [filtrados, setFiltrados] = useState([]);
useEffect(() => {
setFiltrados(
productos.filter((producto) => producto.nombre.includes(busqueda)),
);
}, [productos, busqueda]);
return <ul>{/* renderizado */}</ul>;
}

La lista filtrada es estado derivado. Puede calcularse sin efecto:

function Lista({ productos, busqueda }) {
const filtrados = productos.filter((producto) =>
producto.nombre.toLowerCase().includes(busqueda.toLowerCase()),
);
return (
<ul>
{filtrados.map((producto) => (
<li key={producto.id}>{producto.nombre}</li>
))}
</ul>
);
}

Tampoco necesitas un efecto para responder a una acción del usuario si puedes hacerlo en el propio manejador del evento.

function FormularioPedido() {
function manejarEnvio(event) {
event.preventDefault();
// La acción ocurre porque el usuario envía el formulario.
// No hace falta esperar a un useEffect.
}
return <form onSubmit={manejarEnvio}>{/* campos */}</form>;
}

Preguntas útiles antes de usar useEffect:

  • ¿Estoy sincronizando con algo externo a React?
  • ¿Puedo calcular este valor a partir de props o estado?
  • ¿Puedo ejecutar esta acción dentro de un evento?
  • ¿Estoy creando una segunda fuente de verdad?
  • ¿Necesito limpiar algo cuando el componente desaparezca?

8. Peticiones remotas con estados de carga y error

Cuando una pantalla consume una API, no basta con guardar los datos. También debes representar qué ocurre mientras se cargan, qué pasa si hay error y qué se muestra cuando la respuesta llega vacía.

Ejemplo básico con useEffect:

function ProductosRemotos() {
const [productos, setProductos] = useState([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignorar = false;
async function cargarProductos() {
try {
setCargando(true);
setError(null);
const respuesta = await fetch('/api/productos.json');
if (!respuesta.ok) {
throw new Error('No se pudieron cargar los productos');
}
const datos = await respuesta.json();
if (!ignorar) {
setProductos(datos);
}
} catch (error) {
if (!ignorar) {
setError(error.message);
}
} finally {
if (!ignorar) {
setCargando(false);
}
}
}
cargarProductos();
return () => {
ignorar = true;
};
}, []);
if (cargando) {
return <p>Cargando productos...</p>;
}
if (error) {
return <p role="alert">{error}</p>;
}
if (productos.length === 0) {
return <p>No hay productos disponibles.</p>;
}
return <ListaProductos productos={productos} />;
}

La variable ignorar evita que una respuesta tardía intente modificar el estado cuando el componente ya no debe actualizarse. En proyectos reales también puedes usar AbortController para cancelar peticiones cuando sea adecuado.

Estados mínimos recomendables al consumir datos:

EstadoPara qué sirve
cargandoMostrar que la petición está en curso.
errorInformar de un fallo de red, servidor o formato.
datosGuardar la respuesta útil.
vacíoMostrar un mensaje si la respuesta no contiene elementos.

9. Hooks personalizados para reutilizar lógica

Un hook personalizado es una función que empieza por use y que puede usar otros hooks dentro. Sirve para extraer lógica reutilizable de un componente sin crear componentes artificiales.

Si varios componentes cargan datos de forma similar, puedes extraer esa lógica:

function useProductos() {
const [productos, setProductos] = useState([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignorar = false;
async function cargar() {
try {
const respuesta = await fetch('/api/productos.json');
if (!respuesta.ok) {
throw new Error('No se pudieron cargar los productos');
}
const datos = await respuesta.json();
if (!ignorar) {
setProductos(datos);
}
} catch (error) {
if (!ignorar) {
setError(error.message);
}
} finally {
if (!ignorar) {
setCargando(false);
}
}
}
cargar();
return () => {
ignorar = true;
};
}, []);
return { productos, cargando, error };
}

Uso desde un componente:

function CatalogoPage() {
const { productos, cargando, error } = useProductos();
if (cargando) {
return <p>Cargando...</p>;
}
if (error) {
return <p role="alert">{error}</p>;
}
return <ListaProductos productos={productos} />;
}

Reglas prácticas:

  • El nombre debe empezar por use, como useProductos, useFavoritos o useTema.
  • Debe representar una intención clara, no un conjunto arbitrario de instrucciones.
  • Puede devolver datos, funciones o estados de carga.
  • No debe usarse para ocultar lógica que solo se usa una vez y ya es clara en el componente.

10. Contexto para datos compartidos entre componentes lejanos

Las props son la forma normal de pasar datos de un componente padre a un hijo. Pero si un dato debe llegar a muchos niveles de profundidad, pasar props por componentes intermedios puede volverse incómodo.

El contexto permite que componentes lejanos lean un dato compartido sin pasarlo manualmente por cada nivel.

Ejemplo de contexto para favoritos:

const FavoritosContext = createContext(null);
function FavoritosProvider({ children }) {
const [favoritos, setFavoritos] = useState([]);
function alternarFavorito(id) {
setFavoritos((favoritosActuales) =>
favoritosActuales.includes(id)
? favoritosActuales.filter((favoritoId) => favoritoId !== id)
: [...favoritosActuales, id],
);
}
const value = { favoritos, alternarFavorito };
return (
<FavoritosContext.Provider value={value}>
{children}
</FavoritosContext.Provider>
);
}

Hook para consumir el contexto:

function useFavoritos() {
const contexto = useContext(FavoritosContext);
if (contexto === null) {
throw new Error('useFavoritos debe usarse dentro de FavoritosProvider');
}
return contexto;
}

Uso en una tarjeta:

function ProductoCard({ producto }) {
const { favoritos, alternarFavorito } = useFavoritos();
const esFavorito = favoritos.includes(producto.id);
return (
<article>
<h2>{producto.nombre}</h2>
<button onClick={() => alternarFavorito(producto.id)}>
{esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos'}
</button>
</article>
);
}

El contexto es útil para datos transversales: tema visual, idioma, usuario autenticado, preferencias o favoritos. No debe usarse para evitar pensar dónde debe vivir el estado. Si solo hay relación padre-hijo directa, las props suelen ser más claras.

11. Refs para acceder a valores o elementos externos al renderizado

useRef permite guardar un valor que permanece entre renderizados sin provocar un nuevo renderizado cuando cambia. También puede usarse para acceder a un elemento del DOM gestionado por React.

Ejemplo: enfocar un input al pulsar un botón.

function BuscadorConFoco() {
const inputRef = useRef(null);
function enfocarBuscador() {
inputRef.current.focus();
}
return (
<div>
<label htmlFor="busqueda">Buscar producto</label>
<input id="busqueda" ref={inputRef} />
<button type="button" onClick={enfocarBuscador}>
Enfocar buscador
</button>
</div>
);
}

Ejemplo: guardar un identificador de temporizador.

function Temporizador() {
const intervaloRef = useRef(null);
function iniciar() {
intervaloRef.current = setInterval(() => {
console.log('tick');
}, 1000);
}
function detener() {
clearInterval(intervaloRef.current);
}
return (
<div>
<button onClick={iniciar}>Iniciar</button>
<button onClick={detener}>Detener</button>
</div>
);
}

Usa refs para casos concretos:

  • Enfocar un campo.
  • Medir o acceder a un elemento del DOM.
  • Guardar un identificador de temporizador.
  • Mantener un valor que no se muestra en pantalla.

No uses refs para sustituir el estado cuando el valor debe mostrarse en la interfaz. Si un cambio debe actualizar la pantalla, usa estado.

12. Formularios con validación y mensajes accesibles

En React, un formulario avanzado debe controlar valores, validar entradas y comunicar errores de forma comprensible para cualquier usuario. La validación del cliente mejora la experiencia, pero no sustituye la validación del servidor.

Ejemplo con validación sencilla:

function FormularioProducto({ onCrear }) {
const [nombre, setNombre] = useState('');
const [precio, setPrecio] = useState('');
const [errores, setErrores] = useState({});
function validar() {
const nuevosErrores = {};
if (nombre.trim().length < 3) {
nuevosErrores.nombre = 'El nombre debe tener al menos 3 caracteres.';
}
if (Number(precio) <= 0) {
nuevosErrores.precio = 'El precio debe ser mayor que cero.';
}
return nuevosErrores;
}
function manejarEnvio(event) {
event.preventDefault();
const nuevosErrores = validar();
setErrores(nuevosErrores);
if (Object.keys(nuevosErrores).length > 0) {
return;
}
onCrear({ nombre: nombre.trim(), precio: Number(precio) });
setNombre('');
setPrecio('');
setErrores({});
}
return (
<form onSubmit={manejarEnvio} noValidate>
<label htmlFor="nombre">Nombre</label>
<input
id="nombre"
value={nombre}
onChange={(event) => setNombre(event.target.value)}
aria-describedby={errores.nombre ? 'error-nombre' : undefined}
/>
{errores.nombre && (
<p id="error-nombre" role="alert">{errores.nombre}</p>
)}
<label htmlFor="precio">Precio</label>
<input
id="precio"
type="number"
min="0"
step="0.01"
value={precio}
onChange={(event) => setPrecio(event.target.value)}
aria-describedby={errores.precio ? 'error-precio' : undefined}
/>
{errores.precio && (
<p id="error-precio" role="alert">{errores.precio}</p>
)}
<button type="submit">Crear producto</button>
</form>
);
}

Detalles importantes:

  • Cada campo tiene label asociado con htmlFor e id.
  • Los mensajes de error se conectan al campo con aria-describedby.
  • role="alert" ayuda a anunciar cambios relevantes.
  • El formulario no recarga la página porque se usa event.preventDefault().
  • La validación se ejecuta al enviar, no en cada tecla si no aporta valor.

13. Organización de carpetas en una aplicación React

React no impone una estructura única. Para una aplicación educativa mediana, una organización sencilla puede ser suficiente:

src/
components/
ProductoCard.jsx
ListaProductos.jsx
PanelFiltros.jsx
context/
FavoritosContext.jsx
hooks/
useProductos.js
useFavoritos.js
pages/
CatalogoPage.jsx
services/
productosService.js
utils/
filtrarProductos.js
App.jsx
main.jsx

Responsabilidades recomendadas:

CarpetaContenido
componentsComponentes reutilizables o partes de una pantalla.
pagesComponentes que representan pantallas completas.
hooksHooks personalizados con lógica reutilizable.
servicesFunciones para comunicarse con APIs o fuentes de datos.
contextProviders y hooks relacionados con contexto.
utilsFunciones puras que no dependen de React.

No necesitas crear todas estas carpetas desde el primer día. La estructura debe crecer con el proyecto. Si solo tienes tres componentes, una carpeta components puede bastar.

14. Introducción a rutas en aplicaciones cliente

Una aplicación React puede tener varias pantallas: catálogo, detalle, carrito, perfil o administración. En una SPA, la navegación suele gestionarse en el cliente mediante rutas.

Conceptualmente, una ruta conecta una URL con una pantalla:

URLPantalla
/Inicio
/productosCatálogo
/productos/15Detalle del producto 15
/favoritosFavoritos

En esta unidad no añadiremos una librería de rutas para evitar desviar el foco. Lo importante es entender la idea: cada pantalla puede ser un componente de página, y la aplicación decide cuál mostrar según la ruta.

Ejemplo conceptual simplificado:

function App() {
return (
<>
<NavPrincipal />
<CatalogoPage />
</>
);
}

Cuando estudies una librería de rutas, como React Router u otra solución del ecosistema, podrás conectar esas páginas a URLs reales. La base de componentes, estado, datos y formularios será la misma.

15. Accesibilidad en componentes interactivos

La accesibilidad no es una capa final. Debe formar parte del diseño del componente desde el principio. Una interfaz React puede ser visualmente correcta y, aun así, resultar difícil de usar con teclado o lector de pantalla.

Buenas prácticas básicas:

  • Usa botones reales para acciones, no div con onClick.
  • Asocia cada input con un label.
  • Mantén un orden lógico de encabezados.
  • No ocultes información solo mediante color.
  • Indica estados de carga y error con texto.
  • Usa aria-describedby para relacionar campos y mensajes.
  • Comprueba que los controles se pueden usar con teclado.

Ejemplo de botón accesible para favoritos:

function BotonFavorito({ esFavorito, onClick }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={esFavorito}
>
{esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos'}
</button>
);
}

aria-pressed comunica que el botón funciona como un interruptor. El texto visible también cambia, por lo que no depende solo del atributo ARIA.

16. Rendimiento sin sobreoptimizar

En React, el primer objetivo debe ser que el código sea correcto y claro. Optimizar antes de tener un problema real puede complicar el proyecto sin aportar mejora perceptible.

Medidas básicas antes de usar hooks de rendimiento:

  • Mantén el estado lo más cerca posible de donde se usa.
  • Evita guardar estado derivado innecesario.
  • Divide componentes grandes cuando mezclen responsabilidades.
  • Usa key estable en listas.
  • Evita cálculos pesados dentro del renderizado si se repiten mucho.

useDeferredValue puede ayudar cuando un valor cambia rápido, como un texto de búsqueda, y una parte de la interfaz puede actualizarse con menor prioridad.

function CatalogoConBusqueda({ productos }) {
const [busqueda, setBusqueda] = useState('');
const busquedaDiferida = useDeferredValue(busqueda);
const productosFiltrados = filtrarProductos(productos, busquedaDiferida);
return (
<>
<input
value={busqueda}
onChange={(event) => setBusqueda(event.target.value)}
/>
<ListaProductos productos={productosFiltrados} />
</>
);
}

useTransition permite marcar una actualización como no urgente. Por ejemplo, escribir en un input debe sentirse inmediato, mientras que recalcular una lista grande puede ser menos prioritario.

function BuscadorAvanzado() {
const [texto, setTexto] = useState('');
const [consulta, setConsulta] = useState('');
const [isPending, startTransition] = useTransition();
function manejarCambio(event) {
const nuevoTexto = event.target.value;
setTexto(nuevoTexto);
startTransition(() => {
setConsulta(nuevoTexto);
});
}
return (
<>
<input value={texto} onChange={manejarCambio} />
{isPending && <p>Actualizando resultados...</p>}
<Resultados consulta={consulta} />
</>
);
}

No añadas useMemo o useCallback por costumbre. Son herramientas útiles cuando existe un cálculo caro, una referencia que provoca renderizados innecesarios o un componente optimizado que lo necesita.

17. TypeScript en componentes y hooks

TypeScript ayuda a documentar qué props recibe un componente, qué forma tienen los datos y qué valores pueden manejar los reducers. No cambia el modelo mental de React, pero reduce errores en aplicaciones medianas.

Ejemplo de props tipadas:

type Producto = {
id: number;
nombre: string;
precio: number;
};
type ProductoCardProps = {
producto: Producto;
onEliminar: (id: number) => void;
};
function ProductoCard({ producto, onEliminar }: ProductoCardProps) {
return (
<article>
<h2>{producto.nombre}</h2>
<p>{producto.precio}</p>
<button onClick={() => onEliminar(producto.id)}>Eliminar</button>
</article>
);
}

Ejemplo de acciones para reducer:

type Filtros = {
busqueda: string;
categoria: string;
};
type AccionFiltros =
| { type: 'cambiar-busqueda'; valor: string }
| { type: 'cambiar-categoria'; valor: string }
| { type: 'reiniciar' };

Ventajas principales:

  • Detecta props obligatorias no enviadas.
  • Evita acceder a propiedades inexistentes.
  • Documenta la forma de los datos de la API.
  • Hace más seguros los reducers y hooks personalizados.

Si aún estás consolidando JavaScript, puedes practicar primero con .jsx y después migrar componentes concretos a .tsx.

18. Proyecto guiado: catálogo con filtros, favoritos y datos remotos

El proyecto de esta unidad será un catálogo de productos con carga de datos, filtros, favoritos y separación por responsabilidades. No usaremos backend real ni librerías externas: simularemos datos mediante un archivo JSON o una función de servicio.

Objetivos del proyecto:

  • Cargar productos desde una fuente de datos.
  • Mostrar estados de carga, error y lista vacía.
  • Filtrar por búsqueda y categoría.
  • Gestionar filtros con useReducer.
  • Guardar favoritos en un contexto.
  • Extraer la carga de datos a un hook personalizado.
  • Mantener componentes con responsabilidades claras.

18.1. Definir la estructura del proyecto

Estructura propuesta:

src/
components/
EstadoCarga.jsx
PanelFiltros.jsx
ProductoCard.jsx
ListaProductos.jsx
context/
FavoritosContext.jsx
hooks/
useProductos.js
pages/
CatalogoPage.jsx
services/
productosService.js
utils/
filtrarProductos.js
App.jsx

Esta estructura separa el acceso a datos, la lógica reutilizable, el contexto y la representación visual.

18.2. Crear un servicio de datos

Un servicio encapsula cómo se obtienen los productos. Hoy puede leer un JSON local; mañana podría llamar a una API real.

export async function obtenerProductos() {
const respuesta = await fetch('/productos.json');
if (!respuesta.ok) {
throw new Error('No se pudieron obtener los productos');
}
return respuesta.json();
}

Separar esta función evita que los componentes conozcan detalles de la URL o del formato de petición.

18.3. Crear un hook para cargar productos

El hook useProductos concentra los estados de carga, error y datos.

import { useEffect, useState } from 'react';
import { obtenerProductos } from '../services/productosService';
export function useProductos() {
const [productos, setProductos] = useState([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignorar = false;
async function cargar() {
try {
setCargando(true);
setError(null);
const datos = await obtenerProductos();
if (!ignorar) {
setProductos(datos);
}
} catch (error) {
if (!ignorar) {
setError(error.message);
}
} finally {
if (!ignorar) {
setCargando(false);
}
}
}
cargar();
return () => {
ignorar = true;
};
}, []);
return { productos, cargando, error };
}

El componente que use este hook no tendrá que repetir la lógica de petición.

18.4. Gestionar filtros con useReducer

Los filtros tienen varias acciones relacionadas, por eso useReducer encaja bien.

const filtrosIniciales = {
busqueda: '',
categoria: 'todas',
soloFavoritos: false,
};
function filtrosReducer(filtros, accion) {
if (accion.type === 'busqueda') {
return { ...filtros, busqueda: accion.valor };
}
if (accion.type === 'categoria') {
return { ...filtros, categoria: accion.valor };
}
if (accion.type === 'favoritos') {
return { ...filtros, soloFavoritos: !filtros.soloFavoritos };
}
if (accion.type === 'reiniciar') {
return filtrosIniciales;
}
return filtros;
}

Función pura para aplicar filtros:

export function filtrarProductos(productos, filtros, favoritos) {
return productos.filter((producto) => {
const coincideTexto = producto.nombre
.toLowerCase()
.includes(filtros.busqueda.toLowerCase());
const coincideCategoria =
filtros.categoria === 'todas' || producto.categoria === filtros.categoria;
const coincideFavorito =
!filtros.soloFavoritos || favoritos.includes(producto.id);
return coincideTexto && coincideCategoria && coincideFavorito;
});
}

Como filtrarProductos no depende de React, puede probarse mentalmente como una función normal de JavaScript.

18.5. Compartir favoritos con contexto

El contexto permite que cualquier tarjeta consulte o cambie favoritos sin pasar props por toda la jerarquía.

import { createContext, useContext, useState } from 'react';
const FavoritosContext = createContext(null);
export function FavoritosProvider({ children }) {
const [favoritos, setFavoritos] = useState([]);
function alternarFavorito(id) {
setFavoritos((favoritosActuales) =>
favoritosActuales.includes(id)
? favoritosActuales.filter((favoritoId) => favoritoId !== id)
: [...favoritosActuales, id],
);
}
return (
<FavoritosContext.Provider value={{ favoritos, alternarFavorito }}>
{children}
</FavoritosContext.Provider>
);
}
export function useFavoritos() {
const contexto = useContext(FavoritosContext);
if (contexto === null) {
throw new Error('useFavoritos debe usarse dentro de FavoritosProvider');
}
return contexto;
}

En App se envuelve la pantalla:

function App() {
return (
<FavoritosProvider>
<CatalogoPage />
</FavoritosProvider>
);
}

18.6. Componer la pantalla principal

La pantalla principal combina las piezas anteriores.

function CatalogoPage() {
const { productos, cargando, error } = useProductos();
const { favoritos } = useFavoritos();
const [filtros, dispatch] = useReducer(filtrosReducer, filtrosIniciales);
const productosFiltrados = filtrarProductos(productos, filtros, favoritos);
return (
<main>
<h1>Catálogo de productos</h1>
<PanelFiltros filtros={filtros} dispatch={dispatch} />
<EstadoCarga cargando={cargando} error={error} />
{!cargando && !error && (
<ListaProductos productos={productosFiltrados} />
)}
</main>
);
}

La lista delega cada tarjeta:

function ListaProductos({ productos }) {
if (productos.length === 0) {
return <p>No hay productos que coincidan con los filtros.</p>;
}
return (
<section aria-label="Productos encontrados">
{productos.map((producto) => (
<ProductoCard key={producto.id} producto={producto} />
))}
</section>
);
}

La tarjeta usa el contexto de favoritos:

function ProductoCard({ producto }) {
const { favoritos, alternarFavorito } = useFavoritos();
const esFavorito = favoritos.includes(producto.id);
return (
<article>
<h2>{producto.nombre}</h2>
<p>{producto.precio}</p>
<p>{producto.categoria}</p>
<button
type="button"
aria-pressed={esFavorito}
onClick={() => alternarFavorito(producto.id)}
>
{esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos'}
</button>
</article>
);
}

El proyecto queda dividido en piezas pequeñas:

  • productosService obtiene los datos.
  • useProductos gestiona carga y error.
  • filtrosReducer centraliza la lógica de filtros.
  • FavoritosProvider comparte favoritos.
  • CatalogoPage compone la pantalla.
  • ListaProductos y ProductoCard se encargan de la presentación.

19. Criterios para elegir una solución

Cuando tengas dudas, usa estos criterios:

SituaciónSolución inicial razonable
Un dato solo lo usa un componente.useState local.
Dos hijos necesitan el mismo dato.Subir estado al padre común.
Un valor se calcula desde otros datos.Estado derivado, sin useState adicional.
Hay muchas acciones sobre el mismo estado.useReducer.
Varios componentes lejanos necesitan un dato.Contexto, si pasar props complica el árbol.
Se repite lógica con hooks.Hook personalizado.
Hay que sincronizar con API, temporizador o DOM externo.useEffect con dependencias y limpieza.
Hay que enfocar un campo o guardar un valor no visual.useRef.
La interfaz va lenta al escribir o filtrar.Revisar estructura y considerar useDeferredValue o useTransition.

La mejor arquitectura no es la más sofisticada, sino la que permite entender, cambiar y probar la aplicación con menos esfuerzo.

20. Ejercicios

Ejercicio 1: Separar componentes por responsabilidad

Toma un componente App que tenga buscador, lista, formulario y mensajes. Divídelo en al menos tres componentes con nombres claros y responsabilidad única.

Ejercicio 2: Identificar estado derivado

Revisa una lista filtrada guardada en estado. Reescríbela para calcularla directamente a partir de productos y busqueda durante el renderizado.

Ejercicio 3: Actualizar arrays sin mutación

Crea una lista de tareas y añade acciones para completar, eliminar y renombrar una tarea usando map, filter y copias de objeto.

Ejercicio 4: Reducer de filtros

Implementa un filtrosReducer con las acciones cambiar-busqueda, cambiar-categoria, alternar-favoritos y reiniciar.

Ejercicio 5: Efecto con limpieza

Crea un componente con un temporizador que muestre segundos transcurridos. Asegúrate de limpiar el intervalo cuando el componente desaparezca.

Ejercicio 6: Carga remota con estados

Carga una lista desde un JSON local o API de prueba. Muestra estados de carga, error, lista vacía y resultados.

Ejercicio 7: Hook personalizado

Extrae la lógica del ejercicio anterior a un hook llamado useDatos o useProductos, devolviendo datos, carga y error.

Ejercicio 8: Contexto de favoritos

Crea un contexto para favoritos y úsalo desde varias tarjetas de producto sin pasar favoritos por props intermedias.

Ejercicio 9: Formulario accesible

Construye un formulario de alta de producto con validación, label, mensajes relacionados mediante aria-describedby y mensajes de error visibles.

Ejercicio 10: Proyecto integrador

Construye el catálogo completo de la unidad: carga de productos, filtros con useReducer, favoritos con contexto, hook de datos, componentes separados y mensajes accesibles.