React y SSR para los que vienen de Blade, jQuery y React 15
Para construir el frontend nuevo con criterio: qué cambia respecto a lo que hacemos hoy en l-librodereservas, l-web202101 y los widgets, qué trampas tiene React moderno con renderizado en servidor, y cómo se escribe, se prueba y se mergea en m-f-libro.
Ya sabes hacer interfaces. Lo que necesitas es un mapa (esto de Blade o de jQuery es aquello en la casa nueva), una lista corta de trampas (dónde React 19 con SSR se comporta distinto de lo que tu intuición espera), y el estilo de la casa: m-f-libro ya tiene metodología de estado escrita, capas de hooks y herramientas que la hacen cumplir.
Para quién y qué asume
Para quienes hoy mantienen el libro viejo (React 15 con clases, fbemitter, 48 claves de localStorage y una app jQuery embebida), las vistas Blade del marketplace y de los widgets (21.000 líneas de JavaScript inline con new Vue() dentro de plantillas PHP), y van a hacer PRs en m-f-libro o en el frontend nuevo del marketplace. Asume que sabes JavaScript, que has visto React alguna vez y que leíste Criterio Mesa247: los vicios 6, 8, 11, 17, 18 y 19 son de frontend y aquí solo se traducen.
Cambian tres cosas de fondo:
- El servidor vuelve a renderizar, pero de otra forma. Con Blade, PHP armaba el HTML y el JS lo "animaba" después. Con TanStack Start, el mismo componente se ejecuta en el servidor para producir HTML y otra vez en el navegador para hacerlo interactivo (hidratación). La pregunta nueva de cada línea es: ¿esto corre en el servidor, en el cliente o en los dos? (sección SSR).
- El estado tiene dueño. Nada de "lo guardo en localStorage y lo leo donde haga falta". Los datos del servidor los posee TanStack Query (con su caché, invalidación y refetch); el estado de interfaz lo posee Jotai. Está escrito en
docs/state-management-methodology.mdy aquí se explica por qué (sección Estado). - Los tipos y las herramientas hacen cumplir el criterio. TypeScript estricto, Zod en los bordes, ESLint + Prettier (
pnpm check), Vitest. Lo que en jQuery era "abrir la consola y ver qué llega", aquí es un tipo que no compila.
De Blade, jQuery y React 15 a la casa nueva: el mapa
| Lo que haces hoy | En m-f-libro / marketplace nuevo | Nota |
|---|---|---|
Vista Blade + controlador que arma $data[] | Ruta en src/routes/*.tsx con loader + componente | File-based: el archivo es la URL. El loader trae los datos (en servidor si hay SSR); el componente pinta. |
$.ajax / axios.get dentro del componente, resultado a this.state | src/hooks/queries/use-get-*.ts sobre useGet (TanStack Query) | Un hook por recurso; clave de query estable; caché, reintentos y cancelación gratis. Nunca axios directo en un componente. |
Formulario jQuery / onChange a mano | TanStack Form + esquema Zod | El esquema valida en el borde y da los tipos; el mismo Zod sirve en el servidor. |
this.setState, fbemitter, Config singleton, localStorage.localId | Jotai: átomos por dominio en src/stores/; atomWithStorage solo para preferencias de UI | Estado de interfaz, no datos del servidor. Derivados con atom(get => …), no con useEffect. |
| Enviar cambios y volver a pedir todo | src/hooks/mutations/use-*.ts + invalidateQueries en onSuccess | La mutación no escribe el payload en un átomo: invalida la clave y Query vuelve a traer. |
moment + moment-timezone + moment.locale('es') en 21 archivos | Luxon con la zona del local, en un helper | Zona por restaurante (misma regla que el backend); formatos en constantes. |
Token en localStorage.token y en la URL #/<TOKEN>/… | Hoy: authTokenAtom (atomWithStorage). Objetivo: cookie HttpOnly emitida por el servidor / server function | Es el vicio 6 de la guía de criterio. Ver vicios que no se traen. |
@lang() / textos quemados en español | i18next + react-i18next, JSON por idioma en src/locales/ | Todo texto visible pasa por t(). Multipaís desde el día uno. |
| Bootstrap 3/4 + CSS por local | Tailwind v4 + shadcn (new-york, base zinc) | Componentes accesibles ya hechos (Radix). Se agregan con pnpm dlx shadcn@latest add <name>. |
laravel-mix / react-scripts pre-release / build en la laptop | Vite 7 + TanStack Start; pnpm build; deploy con Wrangler a Cloudflare desde CI | Sin build/ en git; versión por SHA (build:version). |
console.log ×77 y "abrir DevTools" | Sentry (@sentry/tanstackstart-react) + logs estructurados; DevTools de Router y Query | La consola no es un canal de producto. |
| Sin tests (1 en 35k líneas) | Vitest + Testing Library (36 archivos de test hoy); ESLint como gate | Se testea comportamiento (lo que ve el usuario), no implementación. |
URLs por window.location.host en un mapa gigante | Config por entorno validada con Zod (src/env.ts) — pendiente de migrar el mapa de urls.ts | Ver vicios: hoy localhost apunta al backend de producción por URL cruda de Cloud Run. |
Cambiar la cabeza: cinco ideas antes de la sintaxis
- La interfaz es una función del estado.
En jQuery cambias el DOM a mano (
$('#x').show()); en React describes cómo se ve la interfaz para un estado y React actualiza el DOM. Si te encuentras "buscando el elemento para cambiarlo", estás peleando con el modelo. Cambia el estado. - Cada línea corre en algún lado.
Servidor (loader, server function, primer render), cliente (eventos, efectos, hidratación) o los dos (el cuerpo del componente).
window,localStorage,new Date()y "el ancho de la pantalla" solo existen en uno de ellos. La pregunta se hace antes de escribir la línea. - Los datos tienen dueño y los dueños son dos.
Query para lo que viene del servidor; Jotai para lo que decide el usuario en la pantalla. Un dato con dos dueños se desincroniza; es lo que pasaba con
Config+localStorage+fbemitteren el libro viejo. - Componentes pequeños, hooks para la lógica.
Un componente pinta; un hook (
useGetTables,useAssignTable, unuseAtom) trae o cambia datos.ReservationInfo.jsxtenía 6.427 líneas y 74 claves de state: aquí eso son treinta componentes y diez hooks. - Los tipos son el contrato; Zod es la aduana.
Lo que entra por la red o por un formulario se valida con Zod y sale tipado. Lo que viaja entre componentes va tipado por TypeScript.
anyes una deuda con nombre.
Sintaxis: lo que escribías antes → lo que se escribe ahora
| Antes (React 15 / jQuery / Blade) | Ahora (React 19 + TS) | Nota |
|---|---|---|
class X extends React.Component { render() {…} } | function X(props: Props) { return … } | Sin clases nuevas. Un componente es una función que devuelve JSX. |
this.state = {…}; this.setState({a: 1}) | const [a, setA] = useState(0) · átomo Jotai si es compartido | Estado local mínimo; lo compartido, en un átomo con nombre. |
componentDidMount() { fetch… } | loader de la ruta, o useGetX() (Query) | El fetch no vive en el componente. |
componentDidUpdate(prev) { if (prev.id !== this.props.id) … } | Clave de query que incluye id; casi nunca useEffect | Query refetchea sola cuando cambia la clave. |
componentWillUnmount() { remove listener } | useEffect(() => { … ; return () => cleanup }, [deps]) | El cleanup va en la misma función; en el libro viejo faltaban 22 de 37. |
this.props.children, PropTypes | { children }: { children: React.ReactNode }, tipos TS | Los tipos reemplazan a PropTypes y los verifica el compilador. |
refs por string, ReactDOM.findDOMNode | useRef<HTMLDivElement>(null) y ref como prop normal (React 19) | Ya no hace falta forwardRef para pasar refs. |
$('#lista').append(html) · innerHTML = '…' + msg | {items.map(i => <Item key={i.id} … />)} | Sin concatenar HTML: JSX escapa por defecto. dangerouslySetInnerHTML es el {!! !!} de aquí. |
if (cond) { $('#x').show() } else { hide() } | {cond && <X />} · {cond ? <A/> : <B/>} | Renderizado condicional, no mostrar/ocultar. |
$('form').serialize(), onChange a mano por campo | TanStack Form + Zod: form.Field name="email" validators={{ onChange: schema }} | Validación declarativa, errores por campo, tipos. |
<a href="#" onClick={…}> · <div onClick> | <button type="button" onClick> · <Link to="/ruta"> | Semántica y accesibilidad. 76 onClick en div/span en el libro viejo. |
Rutas por window.location.hash, HOC withRouter | createFileRoute('/reservation/$id'), Route.useParams(), useNavigate() | Tipadas: un parámetro mal escrito no compila. |
Store.getState() global · mixins | Hooks propios (useX) y useAtom(xAtom) | La reutilización es por hooks, no por herencia. |
Spinner manual con loading: true | <Suspense fallback> primera carga; isFetching para refrescos | Regla de la metodología: primera carga → Suspense; fondo → indicador. |
Try/catch en cada handler y alert(err) | errorComponent de la ruta + Error Boundary + Sentry | Un lugar para los errores, no cien. |
moment().format('YYYY-MM-DD') | DateTime.now().setZone(local.tz).toISODate() | Luxon; zona explícita; formatos en constantes. |
Un componente típico, antes y ahora
class ReservationListItem extends React.Component {
constructor(p){ super(p); this.state = { tables: [], loading: true } }
componentDidMount(){
new TablesApi().getTables(localStorage.localId, r => {
this.setState({ tables: r.data, loading: false })
localStorage.setItem('tablesAdvanceStates', JSON.stringify(r.data))
})
BusEvent.addListener('loadTable', this.reload) // sin remove
}
render(){
if (this.state.loading) return <div>Cargando…</div>
return <div onClick={()=>this.assign()}>{this.state.tables.map(t => <span>{t.name}</span>)}</div>
}
}export function ReservationTables({ localId, zoneId, date }: Props) {
const { data, isPending, isFetching } = useGetTables(localId, { zone_id: zoneId, reservation_date: date })
const [selectedId, setSelectedId] = useAtom(selectedReservationIdAtom) // estado de UI
const assign = useAssignTable(localId, selectedId!) // mutación
if (isPending) return <TablesSkeleton />
return (
<ul aria-busy={isFetching}>
{data.tables.map((t) => (
<li key={t.id}>
<button type="button" onClick={() => assign.mutate({ table_id: t.id })}>{t.name}</button>
</li>
))}
</ul>
)
}Fíjate en lo que no hay: this, localStorage, listeners a mano, HTML concatenado, div clickeable. Y en lo que sí: un hook por recurso, la clave de query decide cuándo refetchear, el estado de UI en un átomo con nombre, la mutación en su hook, key estable, button.
Las 14 trampas del que viene de jQuery, Blade y React 15
state.items.push(x); setState(state) // React no ve el cambio (mismo objeto)
setItems([...items, x]) // nuevo array → sí
setForm({ ...form, email }) // nuevo objeto → sí
El libro viejo tenía 30 mutaciones directas de this.state. Con Jotai igual: setAtom(prev => ({ ...prev, x })).
useEffect para "sincronizar" datos es el vicio número uno.
// NO: fetch → useEffect → atom → leer el atom en todas partes (dos fuentes de verdad)
useEffect(() => { setTablesAtom(data) }, [data])
// SÍ: renderiza data del query directamente; si otro componente lo necesita, usa el mismo hook (la caché es compartida)
Es el anti-patrón nombrado en docs/state-management-methodology.md. useEffect es para efectos externos (suscribirse a Pusher, medir el DOM), no para derivar estado.
window, document y localStorage no existen en el servidor.
const width = window.innerWidth // ReferenceError en SSR
// SÍ: en un useEffect (solo cliente), o con typeof window !== 'undefined', o en una ruta ssr: false
Con ssr: true el componente se ejecuta primero en Node/Workers. Y aunque no reviente, si el servidor pinta "A" y el cliente "B" (porque leyó localStorage), React avisa hydration mismatch y repinta todo. Es la trampa nueva más frecuente.
new Date() en el render produce dos HTML distintos.
<p>Hoy es {new Date().toLocaleDateString()}</p> // servidor en UTC, cliente en Lima → mismatch
// SÍ: la fecha viene del loader/servidor (o del backend: dateServerAtom), con la zona del local, formateada con Luxon
La misma trampa de las 19:00 de la guía de criterio, en el frontend. La zona es del restaurante, no del navegador (moment.tz.guess() como criterio de negocio era el vicio del libro viejo).
key estable o React recicla el elemento equivocado.
{items.map((it, i) => <Row key={i} … />)} // índice: al reordenar/borrar, el estado se pega a la fila equivocada
{items.map((it) => <Row key={it.id} … />)} // id estable
296 .map con 177 key en el libro viejo. Sin key la sala de mesas se re-renderiza entera.
useEffect(() => { const id = setInterval(() => console.log(count), 1000); return () => clearInterval(id) }, [])
// imprime siempre 0. Dependencias correctas ([count]) o setCount(c => c + 1) o useRef para el último valor.
ESLint react-hooks/exhaustive-deps te lo marca; no lo silencies con un comentario.
async directo en useEffect, y efectos que corren dos veces.
useEffect(async () => { … }, []) // no: el efecto no puede devolver una promesa
useEffect(() => { let alive = true; load().then(d => alive && setD(d)); return () => { alive = false } }, [])
// mejor todavía: eso es un query, no un efecto
En desarrollo (StrictMode) los efectos se montan dos veces a propósito: si tu efecto no es idempotente, lo vas a notar. Es una feature.
localStorage ni en la URL.
localStorage.setItem('token', t) // cualquier script del origen lo lee; XSS = sesión robada
'/configuration/#/' + TOKEN + '/…' // historial, Referer, logs
// Objetivo: cookie HttpOnly; Secure; SameSite emitida por el servidor (server function / backend), y el navegador nunca ve el token
Vicio 6 de la guía de criterio: causó el incidente. En m-f-libro hoy authTokenAtom es atomWithStorage('token'): funciona, y es exactamente lo que hay que migrar (ver vicios).
window.location.host con un mapa a mano.
// src/lib/constants/urls.ts (hoy)
localhost: 'https://mesa-backend-prod-…run.app/v1' // localhost → PRODUCCIÓN, por URL cruda de Cloud Run
// SÍ: VITE_API_BASE_URL por entorno validada en env.ts, y en SSR una server function que lee la config del servidor
Vicio 8: cada pnpm dev pega contra la base de producción con un token real, saltándose el balanceador. Y agregar un país es tocar tres mapas.
any, as y ! apagan el compilador justo donde importa.
const r = (await api.get(url)).data as Reservation // "confía en mí"
const r = ReservationSchema.parse((await api.get(url)).data) // Zod valida y tipa
Lo que entra por la red se valida en el borde (mismo criterio que FormRequest/Pydantic). as se permite para tipos que ya validaste, no para saltarte la validación.
dangerouslySetInnerHTML es {!! !!}: requiere justificación y sanitizado.
El skimmer entró por HTML de tenant renderizado sin escapar. JSX escapa por defecto; si el negocio exige HTML (descripciones de restaurante), sanitiza en servidor con lista blanca y ponlo en un componente con nombre (<TrustedHtml />) para que el revisor lo vea.
<button>; una imagen tiene alt; un formulario tiene label.
shadcn/Radix ya trae la accesibilidad hecha; usar div onClick la tira. El libro lo usan restaurantes en tablets: teclado, lector de pantalla y foco importan. eslint-plugin-jsx-a11y lo marca.
import _ from 'lodash' // todo lodash
import debounce from 'lodash/debounce' // solo eso
// moment (con locales) pesa 10× Luxon; Konva solo en la ruta del mapa (import dinámico / code splitting por ruta)
El widget viejo cargaba 822 KB de JS compilado en git. Vite parte por ruta si se lo dejas: cada route es un chunk.
// componente hijo pide sus datos cuando monta → waterfall de 3 saltos
// SÍ: loader de la ruta con queryClient.ensureQueryData de todo lo que la pantalla necesita, en paralelo (Promise.all)
Es el mismo asyncio.gather de la guía de Python. Y en m-f-libro los queries están enabled: false por defecto: cada hook debe decir explícitamente cuándo puede correr (una peculiaridad de la casa que sorprende la primera semana).
SSR: dónde corre cada línea
TanStack Start renderiza en el servidor (Cloudflare Workers) y luego hidrata en el navegador. Así se ve un request a una página pública del marketplace con ssr: true:
beforeLoad y loader de la ruta corren; llaman a server functions o a la API con secretos que el navegador nunca ve.window, sin efectos, sin estado del navegador.Las tres opciones por ruta
export const Route = createFileRoute('/r/$slug')({
ssr: true, // default: loader + render en servidor (marketplace, SEO)
// ssr: 'data-only' // loader en servidor, componente solo en cliente (datos rápidos, UI con window/Konva)
// ssr: false // todo en cliente (el libro: app detrás de login; así está en __root.tsx)
loader: ({ params }) => getLocalPublic({ data: { slug: params.slug } }),
component: LocalPage,
})
Las rutas hijas heredan y solo pueden ser más restrictivas (true → 'data-only' → false, nunca al revés). El <html> siempre se renderiza en servidor (shellComponent) aunque la raíz sea ssr: false. m-f-libro usa ssr: false en la raíz y prerender en el build (HTML estático por ruta): es una SPA con arranque rápido, no SSR; el marketplace nuevo sí necesitará ssr: true por SEO.
Server functions: código de servidor que se llama como una función
// src/server/locales.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
export const getLocalPublic = createServerFn({ method: 'GET' })
.validator(z.object({ slug: z.string().min(1) }))
.handler(async ({ data }) => {
// Esto corre SOLO en el servidor: puede usar la URL interna de la API y una credencial de servidor.
const r = await fetch(`${process.env.API_INTERNAL_URL}/v1/locales/${data.slug}`, {
headers: { Authorization: `Bearer ${process.env.API_SERVER_TOKEN}` },
signal: AbortSignal.timeout(5000),
})
if (r.status === 404) throw notFound()
return LocalPublicSchema.parse(await r.json())
})
En el bundle del cliente el cuerpo se reemplaza por un stub RPC: el código y los secretos no llegan al navegador. Es la respuesta correcta a "el JS necesita llamar a la API con un token": no necesita; llama a una server function y el servidor habla con la API (vicio 6). Con setResponseHeaders/setResponseStatus controlas caché y códigos desde el handler.
Pantallas detrás de login con mucho estado de navegador (el libro: mapa de mesas en Konva, tablets, sesión larga): ssr: false o 'data-only' y prerender del cascarón. SSR paga en páginas públicas que Google debe indexar y en el primer pintado de móviles lentos: el marketplace, la ficha del restaurante, las landings.
Estado: quién es dueño de qué
La regla central de docs/state-management-methodology.md, que se aplica en cada feature que toques:
// respuestas de la API, caché, staleTime, invalidación, refetch, mutaciones
const { data } = useGetReservations(localId, { date })
const create = useCreateReservation(localId, { onSuccess: () => qc.invalidateQueries({ queryKey: ['reservations', localId] }) })// selección, pestañas, paneles, filtros, flags persistidos de UI
export const selectedReservationIdAtom = atom<number | null>(null)
export const sidebarOpenAtom = atomWithStorage('sidebarOpen', true)
export const currentLocalOptionsAtom = atom((get) => get(localsAtom).map(toOption)) // derivado, sin useEffect- No espejes datos del query en un átomo. Render directo desde el query; pásalo por props o selectores.
- Tras una mutación, invalida las claves afectadas, no escribas el payload de vuelta en un átomo.
- Claves de query estables definidas en el archivo del hook del recurso (
['tables', localId, zoneId, date]). - Primera carga → Suspense/skeleton; refresco en segundo plano →
isFetching. - El arranque de auth vive en los loaders (
beforeLoad/loaderde__root.tsxyv1/auth.$token.tsx), no en componentes. - Peculiaridades de la casa:
enabled: falsepor defecto en el QueryClient (cada hook decide cuándo puede correr),staleTime5 min,retry: 3en queries y0en mutaciones,refetchOnWindowFocus: false. ElMyRouterContextcomparte elQueryClienty el store de Jotai para que los loaders lean/escriban átomos.
Capas de hooks: src/hooks/api/* (genéricos: useGet, usePost, usePut, usePatch, useDelete) → src/hooks/queries/use-get-*.ts (uno por recurso, exponen a veces un *Query para ensureQueryData en loaders) → src/hooks/mutations/use-*.ts (una por acción, invalidan en onSuccess). Nunca axios directo en un componente.
Los vicios que no se traen (y los dos que ya se colaron)
| Vicio en el frontend viejo | Su disfraz en React moderno | Así se hace en la casa nueva | Quién lo frena |
|---|---|---|---|
Token en localStorage y en la URL (vicio 6) | atomWithStorage('token') — así está hoy en src/stores/auth-atoms.ts:6-9 | Cookie HttpOnly vía server function/backend; el navegador no ve el token; el intercambio del token de un solo uso ocurre en servidor | Revisión; ticket de migración |
URLs crudas run.app y dev→prod (vicio 8) | Mapa por hostname en src/lib/constants/urls.ts: localhost y libro.mesa247.pe → mesa-backend-prod-….run.app | VITE_API_BASE_URL por entorno (ya validada en env.ts), dev → staging detrás del LB; en SSR, la URL interna solo en servidor | Revisión; env.ts |
Estado global ad-hoc: Config, fbemitter, 48 claves de storage (vicio 19) | Espejar queries en átomos; useEffect de sincronización; átomos "bolsa" | Metodología Query/Jotai; átomos por dominio y derivados | Revisión con la metodología en la mano |
| Componentes de 6.427 líneas (vicio 15) | Un archivo de ruta con toda la pantalla dentro | Ruta delgada + componentes por sección + hooks; docs/components-map.md | Revisión (>300 líneas = partir) |
| Copiar el componente y cambiar diez líneas (vicio 16) | WaitListItem/WalkinListItem otra vez | Un componente parametrizado por kind; shadcn para lo genérico | Revisión |
innerHTML por concatenación (vicio 11) | dangerouslySetInnerHTML | JSX; HTML de tenant sanitizado en servidor y en un componente con nombre | ESLint react/no-danger, revisión |
userType > 8 en 65 sitios (vicio 18) | if (user.role > 8) disperso | Permisos del backend en un átomo derivado can('editar_reserva'); la UI oculta, el servidor autoriza | Revisión |
moment.tz.guess(), moment.locale('es') ×21, formatos literales ×57 (vicio 4) | new Date() en render; DateTime.local() sin zona | Luxon con la zona del local en un helper; formatos en constantes; fecha del servidor | Revisión, tests |
77 console.log, alert() (vicio 22) | Igual | Sentry + notificaciones de la app; no-console: error | ESLint en CI |
| Sin tests, jest roto, lint desactivado en build (vicio 20) | // eslint-disable masivo; tests que solo hacen snapshot | Vitest + Testing Library por comportamiento; pnpm check y pnpm test en CI antes de deploy | CI (pendiente: m-f-libro no tiene .github/) |
build/ versionado, build en la laptop, updates.txt (vicio 21) | dist/ commiteado; pnpm deploy desde una laptop | Wrangler desde CI con wrangler.toml versionado; versión por SHA (build:version) | CI |
Accesibilidad: div onClick, href="#", sin alt | Igual | shadcn/Radix, button, label, alt; eslint-plugin-jsx-a11y | ESLint |
| Bundle de 822 KB en git | Importar librerías enteras; sin code splitting | Imports específicos, chunk por ruta, Konva solo donde se usa; vite build reporta tamaños | Revisión del build |
Cómo está armado el repo
m-f-libro/
├── src/
│ ├── routes/ file-based: __root.tsx (shell, providers, auth bootstrap), index.tsx (3 paneles),
│ │ reservation/, waitlist/, timeline.tsx, configuration.tsx, v1/auth.$token.tsx…
│ ├── routeTree.gen.ts generado por el plugin del router (no se edita)
│ ├── components/ UI por dominio + ui/ (shadcn)
│ ├── hooks/api/ useGet/usePost/usePut/usePatch/useDelete (genéricos sobre axios + Query)
│ ├── hooks/queries/ un archivo por recurso: use-get-tables.ts, use-get-reservations.ts…
│ ├── hooks/mutations/ una por acción: use-assign-table.ts, use-create-reservation.ts…
│ ├── stores/ átomos Jotai por dominio: auth-atoms.ts, app-config-atoms.ts, reservations-atoms.ts…
│ ├── lib/api/client.ts axios singleton + setAuthToken()/setBaseUrl() (se llaman en el bootstrap)
│ ├── lib/constants/ endpoints.ts, urls.ts (mapa por host — a migrar)
│ ├── integrations/tanstack-query/ QueryClient con los defaults de la casa
│ ├── env.ts variables VITE_* validadas con Zod (@t3-oss/env-core)
│ ├── i18n.ts · locales/ i18next
│ ├── types/ · utils/ · styles.css
│ └── test/ · **/__tests__/ vitest + Testing Library (36 archivos hoy)
├── docs/ state-management-methodology.md, api-hooks.md, atoms-dictionary.md, components-map.md,
│ design-system.md, login-flow.md, path-aliases.md (mkdocs: docker compose up docs)
├── vite.config.ts · vitest.config.ts · tsconfig.json (aliases en los dos, a mano)
├── eslint.config.js · prettier.config.js · components.json (shadcn)
├── Dockerfile.dev · docker-compose.yml · Makefile
└── CLAUDE.md léelo primero, siemprepnpm install # pnpm 8.15
pnpm dev # vite en :3000
pnpm test # vitest run (una vez); pnpm exec vitest <patrón> para uno
pnpm check # prettier --write . && eslint --fix ← antes de cada commit (pnpm format NO formatea)
pnpm build # producción; pnpm build:pages emite dist/client/version.json con el SHA
pnpm dlx shadcn@latest add dialog # agregar un componente (dentro del contenedor si usas Docker)(1) Un hook de query no trae nada: falta enabled: true o su condición (default enabled: false). (2) "No hay login": no lo hay; entras por /v1/auth/$token o el root loader te manda al portal del host; el root loader corre una sola vez por sesión (staleTime: Infinity) — para re-auth, router.invalidate(). (3) Agregaste un alias en tsconfig.json y Vite no lo ve: va también en vite.config.ts.
Dos features de punta a punta
A · En el libro (SPA): filtrar la lista de reservas por zona
Un átomo para la selección de zona (UI), el query existente de reservas, y una mutación que invalida. Sin useEffect.
import { atom } from 'jotai'
export const selectedZoneIdAtom = atom<number | null>(null) // estado de UI, no persistidoexport function useGetReservations(localId: Id | null, params: { date: string; zone_id?: number | null }) {
const enabled = !!localId && !!params.date
return useGet<ReservationsApiResponse>(
['reservations', localId, params.date, params.zone_id ?? null], // clave estable: cambia → refetch solo
enabled ? buildEndpointWithParams(LOCAL_ENDPOINTS.RESERVATIONS(localId!), params) : '',
undefined,
{ enabled, placeholderData: keepPreviousData },
)
}export function ReservationList({ localId, date }: { localId: Id; date: string }) {
const [zoneId, setZoneId] = useAtom(selectedZoneIdAtom)
const zones = useGetAmbients(localId)
const reservations = useGetReservations(localId, { date, zone_id: zoneId })
const { t } = useTranslation()
return (
<section aria-labelledby="rl-title">
<h2 id="rl-title">{t('reservations.title')}</h2>
<Select value={zoneId ?? ''} onValueChange={(v) => setZoneId(v ? Number(v) : null)}>…</Select>
{reservations.isPending ? <ListSkeleton /> : (
<ul aria-busy={reservations.isFetching}>
{reservations.data.items.map((r) => <ReservationRow key={r.id} reservation={r} />)}
</ul>
)}
</section>
)
}import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '@/test/msw' // MSW: la API simulada, no axios mockeado
it('filtra por zona y muestra las reservas de esa zona', async () => {
server.use(
http.get('*/locals/11/ambients', () => HttpResponse.json({ items: [{ id: 1, name: 'Terraza' }] })),
http.get('*/locals/11/reservations', ({ request }) => {
const zone = new URL(request.url).searchParams.get('zone_id')
return HttpResponse.json({ items: zone === '1' ? [{ id: 9, name: 'Ana' }] : [] })
}),
)
renderWithProviders(<ReservationList localId={11} date="2026-08-20" />)
await userEvent.click(screen.getByRole('combobox'))
await userEvent.click(screen.getByRole('option', { name: 'Terraza' }))
expect(await screen.findByText('Ana')).toBeInTheDocument()
})B · En el marketplace nuevo (SSR): la ficha pública de un restaurante
import { createFileRoute, notFound } from '@tanstack/react-router'
import { getLocalPublic } from '@/server/locales' // server function del ejemplo de arriba
export const Route = createFileRoute('/r/$slug')({
ssr: true,
loader: ({ params }) => getLocalPublic({ data: { slug: params.slug } }),
head: ({ loaderData }) => ({
meta: [{ title: `${loaderData.nombre} · Mesa247` }, { name: 'description', content: loaderData.descripcion }],
}),
notFoundComponent: () => <LocalNoEncontrado />,
component: LocalPage,
})
function LocalPage() {
const local = Route.useLoaderData() // tipado desde el loader
return (
<article>
<h1>{local.nombre}</h1>
<p>{local.descripcion}</p>
<Disponibilidad localId={local.id} zona={local.zonaHoraria} /> {/* interactivo: Query en cliente */}
</article>
)
}El HTML llega completo (Google lo indexa, el móvil lo pinta sin JS), el token de servidor nunca sale del Worker, y la parte interactiva (disponibilidad) es un componente normal con Query. Si el navegador necesita disponibilidad en vivo, llama a otra server function o a un endpoint público sin credenciales, nunca con el token del servidor.
Cómo se testea
- Vitest + Testing Library, por comportamiento: "el usuario hace X y ve Y". Consultas por rol y texto (
getByRole('button', { name })), no por clase CSS ni por implementación. - MSW para la red: se simula la API HTTP, no se mockea axios ni el hook. Es el "mock boundary" de la guía de Python: mockea el nivel más bajo, no la función bajo prueba.
- Un
renderWithProvidersensrc/test/que montaQueryClientProvider(conretry: false), JotaiProvidercon store limpio, i18n y router de prueba. Cada test parte de cero. - Hooks con
renderHook; átomos derivados concreateStore()ystore.get(atom)(funciones puras: sin render). - Server functions: probar el handler como función (inyectar
fetch), y la ruta con un test de integración que renderice el árbol con datos del loader. - E2E (Playwright) para dos o tres flujos críticos: crear reserva, asignar mesa, checkout público. Pocos, estables.
- Antes de escribir un test: la misma pregunta de TESTING.md del backend: ¿qué bug de producción atraparía?
Checklist de PR en m-f-libro
pnpm checkypnpm testlimpios; TypeScript sinany/as/!nuevos sin justificar.- Datos del servidor en Query (hook en
hooks/queriesomutations); ningúnaxiosen componentes; ningúnuseEffectque espeje queries en átomos. - Estado de UI en átomos con nombre en
stores/; derivados conatom(get => …). - Ningún token, credencial ni URL de servicio en el código, en
localStoragenuevo ni en la URL; config porenv.ts. - Nada de
window/document/new Date()en el cuerpo del render de una ruta con SSR; fechas con la zona del local (Luxon). - Textos por
t();button/label/alt/keyen su sitio; sindangerouslySetInnerHTMLsin sanitizado y justificación. - Componente < 300 líneas; ruta delgada; sin copias de componentes.
- Tests del comportamiento nuevo con MSW; sin
console.log. - Sin dependencias nuevas (o justificadas y pesadas en el bundle).
- Commit
type(scope): description; docs (docs/*.md,CLAUDE.md) actualizadas si cambia una convención. - Puedo explicar cada línea, incluidas las que escribió la IA.
Tres semanas, de jQuery/Blade a un PR mergeado
Semana 1 · React moderno + TypeScript
react.dev/learn (español) completo: describir la UI, interactividad, estado, escapar del paradigma; TS Handbook hasta genéricos básicos. Ejercicio: reescribe WaitListItem del libro viejo como componente de función tipado, con key, button y sin localStorage; test con Testing Library.
Semana 2 · El stack de la casa
TanStack Router (file-based, loaders, params tipados) y Start (SSR selectivo, server functions), Query (claves, staleTime, invalidación), Jotai (átomos, derivados, atomWithStorage), Zod, Tailwind + shadcn. Proyecto: una app de 3 rutas con SSR (lista pública, detalle con head(), formulario con TanStack Form + Zod), una server function con secreto de servidor, MSW en tests, desplegada a Cloudflare Workers desde CI.
Semana 3 · Dentro de m-f-libro
Levanta el repo, lee CLAUDE.md, docs/state-management-methodology.md, docs/api-hooks.md, docs/components-map.md. Primer PR: un test de comportamiento para un componente que no tenga (Testing Library + MSW). Segundo: la feature A de arriba o un componente partido de uno grande. Revisión con alguien del front; retro de 15 minutos: qué del mapa te confundió, para mejorar esta guía.
Con Claude en m-f-libro
El repo ya tiene CLAUDE.md con la arquitectura, los comandos y la metodología de estado. Aplican las reglas de Criterio Mesa247 y estas tres:
- Pide la traducción con el archivo viejo al lado.
"Este es
ReservationListItem.jsxdel libro viejo. Rehazlo como componentes de función siguiendodocs/state-management-methodology.md: qué va a Query, qué a Jotai, qué se parte. Primero el plan, sin código." - Que te diga dónde corre cada línea.
"En esta ruta con
ssr: true, marca qué líneas corren en servidor, cuáles en cliente y cuáles en los dos, y qué puede producir hydration mismatch." - Test primero, con MSW.
"Escribe el test de comportamiento (usuario filtra por zona y ve las reservas) con MSW; no lo hagas pasar todavía." Y luego la implementación.
Guarda el usuario logueado en localStorage para que no se pierda al refrescar.Vicio 6 con otro traje. Ya está en
atomWithStorage y es lo que hay que sacar.Diseña el flujo de auth para que el token nunca llegue al navegador: server function que canjea el token de un solo uso, cookie HttpOnly, y qué cambia enRestricción dicha antes; decisión tuya.__root.tsxyv1/auth.$token.tsx. Trade-offs con el prerender actual.
Recursos, pocos y elegidos
- React · Aprende (español)La documentación nueva. "Escapar del paradigma" y "Manejo de estado" son las dos secciones que curan el jQuery.
- TanStack Start · Router · QuerySSR selectivo, server functions, loaders, claves y caché. Lo que corre en la casa.
- JotaiÁtomos, derivados,
atomWithStorage,atomFamily. Corto. - Zod · TanStack FormValidación en el borde y formularios tipados.
- Testing Library · Vitest · MSWTests por comportamiento con la red simulada.
- Tailwind v4 · shadcn/uiLos componentes vienen accesibles; no los envuelvas en
div onClick. - MDN · Accesibilidad (español) · web.dev · PerformanceLo mínimo de a11y y de rendimiento web (Core Web Vitals) para una app pública.
- Cloudflare Workers · WranglerDónde corre el SSR y cómo se despliega.
- En el repo:
CLAUDE.md,docs/state-management-methodology.md,docs/api-hooks.md,docs/atoms-dictionary.md,docs/components-map.md,docs/login-flow.mdAntes que cualquier recurso externo. Es la casa.