Frontend moderno con React
"El frontend moderno es JavaScript construyendo HTML en el navegador. Es complejo, pero permite experiencias de usuario que antes solo eran posibles con apps nativas."
Qué vas a aprender en este capítulo
React domina el desarrollo frontend con ~70% de market share. Este capítulo te lleva de cero a una SPA funcional: componentes, hooks, manejo de estado del servidor, routing, y deployment.
3.1 SPA vs MPA — ¿qué arquitectura elegir?
📐 Fundamento
MPA (Multi-Page Application):
Cada navegación carga una página HTML nueva del servidor. Modelo tradicional.
Click en link → request HTTP → servidor envía HTML nuevo → browser renderiza
Ejemplos: Wikipedia, blogs, sitios de noticias, e-commerce tradicionales.
SPA (Single-Page Application):
Carga una sola página HTML inicialmente. JavaScript maneja toda la navegación posterior, fetcheando solo datos (JSON).
Carga inicial: HTML + JS bundle (~200KB)
Click en link → JS actualiza la URL + DOM → fetch a API → re-render
Ejemplos: Gmail, Trello, Figma, Twitter.
Híbridos modernos:
- SSR (Server-Side Rendering): el primer render lo hace el servidor (HTML completo) y luego JS toma control. Mejor SEO. Next.js, Nuxt.
- SSG (Static Site Generation): páginas pre-generadas en build time. Súper rápido. Astro, Gatsby.
- Islands architecture: HTML estático con "islas" de interactividad (JS solo donde se necesita). Astro.
| Modelo | Primer load | Navegación | SEO | Complejidad |
|---|---|---|---|---|
| MPA tradicional | Rápido | Lento (full reload) | ✓ | Baja |
| SPA pura | Lento | Muy rápida | ✗ (sin SSR) | Media-alta |
| SSR (Next.js) | Rápido | Rápida | ✓ | Alta |
| SSG (Astro) | Muy rápido | Rápida | ✓ | Media |
Para La Esquina App: SPA con React + Vite. Es app interactiva con dashboards en tiempo real — el SEO no importa (no es público).
3.2 React esencial
📐 Fundamento
Setup con Vite:
npm create vite@latest la-esquina-frontend -- --template react-ts
cd la-esquina-frontend
npm install
npm run dev
Componentes con hooks:
// src/components/Pedido.tsx
import { useState } from 'react';
interface PedidoProps {
id: number;
mesa: number;
total: number;
estado: 'ABIERTO' | 'LISTO' | 'ENTREGADO';
onCambiarEstado: (id: number, nuevoEstado: string) => void;
}
export function Pedido({ id, mesa, total, estado, onCambiarEstado }: PedidoProps) {
const [expandido, setExpandido] = useState(false);
return (
<div className="border rounded-lg p-4 my-2">
<div className="flex justify-between">
<h3>Pedido #{id} — Mesa {mesa}</h3>
<span className={`badge badge-${estado.toLowerCase()}`}>{estado}</span>
</div>
<p>Total: ${total.toFixed(2)}</p>
<button onClick={() => setExpandido(!expandido)}>
{expandido ? 'Contraer' : 'Ver detalle'}
</button>
{expandido && (
<div className="mt-2">
{/* contenido detallado */}
</div>
)}
{estado === 'ABIERTO' && (
<button onClick={() => onCambiarEstado(id, 'LISTO')}>
Marcar como listo
</button>
)}
</div>
);
}
Hooks fundamentales:
import { useState, useEffect, useContext, useMemo, useCallback } from 'react';
// useState — estado local del componente
const [count, setCount] = useState(0);
// useEffect — efectos secundarios (fetch, subscriptions)
useEffect(() => {
console.log('Componente montado');
return () => console.log('Componente desmontado'); // cleanup
}, []); // [] = solo al montar
useEffect(() => {
document.title = `Pedidos: ${count}`;
}, [count]); // [count] = cuando count cambie
// useContext — leer contexto sin prop drilling
const tema = useContext(TemaContext);
// useMemo — memoizar valor caro de calcular
const total = useMemo(() =>
items.reduce((sum, i) => sum + i.precio * i.cantidad, 0),
[items]
);
// useCallback — memoizar función (evitar re-renders innecesarios)
const handleClick = useCallback(() => {
console.log('Click');
}, [/* deps */]);
Custom hooks — extraer lógica reutilizable:
// src/hooks/useDebounce.ts
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Uso: buscar mientras se tipea, pero esperar a que se detenga
function Buscador() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) buscarPlatillos(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
3.3 Manejo de estado del servidor — TanStack Query
💡 Intuición
Manejar manualmente loading/error/cache/refetch para cada API call es tedioso y propenso a bugs. TanStack Query (antes React Query) lo hace automáticamente — es la librería más importante en el ecosistema React moderno.
📐 Fundamento
npm install @tanstack/react-query
// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // datos frescos por 5 minutos
retry: 2
}
}
});
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
Query — leer datos:
import { useQuery } from '@tanstack/react-query';
function ListaPedidos() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['pedidos', 'abiertos'],
queryFn: async () => {
const res = await fetch('/api/pedidos?estado=ABIERTO');
if (!res.ok) throw new Error('Error al cargar');
return res.json();
}
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} onRetry={refetch} />;
return (
<div>
{data.map(p => <Pedido key={p.id} {...p} />)}
</div>
);
}
Mutation — escribir datos:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function FormCrearPedido() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (nuevo: PedidoInput) => {
const res = await fetch('/api/pedidos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nuevo)
});
if (!res.ok) throw new Error('Error');
return res.json();
},
onSuccess: () => {
// Invalidar queries para que se recarguen automáticamente
queryClient.invalidateQueries({ queryKey: ['pedidos'] });
}
});
return (
<form onSubmit={e => {
e.preventDefault();
mutation.mutate({ mesaId: 3, items: [...] });
}}>
{/* campos */}
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Creando...' : 'Crear pedido'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Optimistic updates (UI actualizada antes de respuesta del server):
const mutation = useMutation({
mutationFn: actualizarEstado,
onMutate: async ({ id, nuevoEstado }) => {
// Cancelar queries en curso
await queryClient.cancelQueries({ queryKey: ['pedidos'] });
// Snapshot del valor previo
const prev = queryClient.getQueryData(['pedidos']);
// Actualización optimista
queryClient.setQueryData(['pedidos'], (old: any[]) =>
old.map(p => p.id === id ? { ...p, estado: nuevoEstado } : p)
);
return { prev }; // contexto para rollback
},
onError: (err, variables, context) => {
// Si falla, restaurar el valor previo
queryClient.setQueryData(['pedidos'], context?.prev);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['pedidos'] });
}
});
3.4 Routing con React Router
📐 Fundamento
npm install react-router-dom
// src/App.tsx
import { BrowserRouter, Routes, Route, Link, useParams, useNavigate } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/pedidos">Pedidos</Link>
<Link to="/cocina">Cocina</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/pedidos" element={<ListaPedidos />} />
<Route path="/pedidos/:id" element={<DetallePedido />} />
<Route path="/cocina" element={<PantallaCocina />} />
<Route element={<RutaProtegida rol="admin" />}>
<Route path="/admin/*" element={<AdminPanel />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
function DetallePedido() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data } = useQuery({
queryKey: ['pedido', id],
queryFn: () => fetch(`/api/pedidos/${id}`).then(r => r.json())
});
return (
<div>
<button onClick={() => navigate(-1)}>← Volver</button>
{/* detalles del pedido */}
</div>
);
}
Rutas protegidas:
function RutaProtegida({ rol }: { rol: string }) {
const user = useUsuarioActual();
if (!user) return <Navigate to="/login" replace />;
if (rol && user.rol !== rol) return <Navigate to="/403" replace />;
return <Outlet />; // renderiza las rutas anidadas
}
3.5 Patrones y mejores prácticas
📐 Fundamento
1. Estructura de archivos (feature-based):
src/
├── components/ # componentes UI compartidos
│ ├── Button.tsx
│ ├── Spinner.tsx
│ └── Modal.tsx
├── features/ # módulos por funcionalidad
│ ├── pedidos/
│ │ ├── api.ts
│ │ ├── hooks.ts
│ │ ├── ListaPedidos.tsx
│ │ └── DetallePedido.tsx
│ └── cocina/
│ └── ...
├── hooks/ # hooks compartidos
├── types/ # tipos TypeScript
└── App.tsx
2. Componentes pequeños y de una sola responsabilidad:
// MAL — componente que hace todo
function PedidosPage() {
// 200 líneas: fetch + filtros + renderizado + modal + ...
}
// BIEN — componentes pequeños y composables
function PedidosPage() {
return (
<div>
<PedidosFiltros />
<PedidosLista />
<PedidosCrearModal />
</div>
);
}
3. TypeScript estricto:
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
}
}
4. Evitar prop drilling con Context o stores:
// Para estado global pequeño: Context
const UsuarioContext = createContext<Usuario | null>(null);
// Para estado global complejo: Zustand (más simple que Redux)
import { create } from 'zustand';
const useStore = create((set) => ({
carrito: [],
agregarItem: (item) => set(state => ({ carrito: [...state.carrito, item] })),
vaciar: () => set({ carrito: [] })
}));
5. Lazy loading de rutas:
import { lazy, Suspense } from 'react';
const AdminPanel = lazy(() => import('./features/admin/AdminPanel'));
<Suspense fallback={<Spinner />}>
<AdminPanel />
</Suspense>
// AdminPanel solo se descarga cuando el usuario navega a /admin
6. Accesibilidad:
// Usar elementos semánticos (button, nav, header) en lugar de div para todo
<button onClick={handleClick}>Aceptar</button> // ✓ accesible por teclado
<div onClick={handleClick}>Aceptar</div> // ✗ no se navega con Tab
// Labels en inputs
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// aria-label cuando no hay texto visible
<button aria-label="Cerrar"><X /></button>
3.6 Ejercicios
✏️ Ejercicio 3.1 — Componente con hooks
Implementá un componente Contador con las siguientes especificaciones:
- Inicia en 0.
- Tiene botones
+,-, yReset. - Muestra el valor actual.
- Persiste el valor en localStorage (cuando se recarga la página, mantiene el último valor).
- Muestra un mensaje "Récord: N" donde N es el máximo histórico alcanzado.
Solución
import { useState, useEffect } from 'react';
function Contador() {
const [valor, setValor] = useState(() => {
return parseInt(localStorage.getItem('contador') || '0');
});
const [record, setRecord] = useState(() => {
return parseInt(localStorage.getItem('record') || '0');
});
useEffect(() => {
localStorage.setItem('contador', valor.toString());
if (valor > record) {
setRecord(valor);
localStorage.setItem('record', valor.toString());
}
}, [valor, record]);
return (
<div>
<h2>Contador: {valor}</h2>
<p>Récord: {record}</p>
<button onClick={() => setValor(v => v + 1)}>+</button>
<button onClick={() => setValor(v => v - 1)}>-</button>
<button onClick={() => setValor(0)}>Reset</button>
</div>
);
}
Notas:
useState(() => ...)con función — solo se ejecuta una vez al montar (lazy initialization).useEffectcon dependencias[valor, record]— corre cuando cualquiera cambia.setValor(v => v + 1)— usar callback cuando el nuevo estado depende del anterior (evita stale closures).
3.7 Para profundizar
- react.dev — documentación oficial nueva (excelente).
- TanStack Query docs — manejo de estado del servidor.
- Patterns.dev — patrones modernos de frontend.
- Siguiente: Tiempo real y WebSockets.
Definiciones nuevas: SPA, MPA, SSR, SSG, React, hooks (useState, useEffect, useContext, useMemo, useCallback), custom hooks, TanStack Query, query, mutation, optimistic updates, React Router, prop drilling, lazy loading, code splitting.