Guía interna · Ingeniería Mesa247 · agosto 2026

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.

Stack React 19 · TypeScript · Vite 7 · TanStack Start/Router/Query/Form · Jotai · Zod · Tailwind v4 + shadcn · i18next · Luxon · Vitest · Cloudflare Guías hermanas Criterio Mesa247 · Python para m-b-core Documento interno
Empezar

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.md y 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 hoyEn m-f-libro / marketplace nuevoNota
Vista Blade + controlador que arma $data[]Ruta en src/routes/*.tsx con loader + componenteFile-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.statesrc/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 manoTanStack Form + esquema ZodEl esquema valida en el borde y da los tipos; el mismo Zod sirve en el servidor.
this.setState, fbemitter, Config singleton, localStorage.localIdJotai: átomos por dominio en src/stores/; atomWithStorage solo para preferencias de UIEstado de interfaz, no datos del servidor. Derivados con atom(get => …), no con useEffect.
Enviar cambios y volver a pedir todosrc/hooks/mutations/use-*.ts + invalidateQueries en onSuccessLa 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 archivosLuxon con la zona del local, en un helperZona 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 functionEs el vicio 6 de la guía de criterio. Ver vicios que no se traen.
@lang() / textos quemados en españoli18next + 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 localTailwind 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 laptopVite 7 + TanStack Start; pnpm build; deploy con Wrangler a Cloudflare desde CISin 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 QueryLa consola no es un canal de producto.
Sin tests (1 en 35k líneas)Vitest + Testing Library (36 archivos de test hoy); ESLint como gateSe testea comportamiento (lo que ve el usuario), no implementación.
URLs por window.location.host en un mapa giganteConfig por entorno validada con Zod (src/env.ts) — pendiente de migrar el mapa de urls.tsVer vicios: hoy localhost apunta al backend de producción por URL cruda de Cloud Run.

Cambiar la cabeza: cinco ideas antes de la sintaxis

  1. 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.

  2. 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.

  3. 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 + fbemitter en el libro viejo.

  4. Componentes pequeños, hooks para la lógica.

    Un componente pinta; un hook (useGetTables, useAssignTable, un useAtom) trae o cambia datos. ReservationInfo.jsx tenía 6.427 líneas y 74 claves de state: aquí eso son treinta componentes y diez hooks.

  5. 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. any es una deuda con nombre.

React

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 compartidoEstado 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 useEffectQuery 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 TSLos tipos reemplazan a PropTypes y los verifica el compilador.
refs por string, ReactDOM.findDOMNodeuseRef<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 campoTanStack 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 withRoutercreateFileRoute('/reservation/$id'), Route.useParams(), useNavigate()Tipadas: un parámetro mal escrito no compila.
Store.getState() global · mixinsHooks 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 refrescosRegla de la metodología: primera carga → Suspense; fondo → indicador.
Try/catch en cada handler y alert(err)errorComponent de la ruta + Error Boundary + SentryUn 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

Libro viejo (React 15)
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>
  }
}
m-f-libro (React 19 + TS)
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

1 · Mutar el estado no re-renderiza.
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 })).

2 · 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.

3 · 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.

4 · 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).

5 · 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.

6 · Closures viejos: el handler "recuerda" el valor de cuando se creó.
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.

7 · 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.

8 · El token de sesión no vive en 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).

9 · Las URLs de la API no se deciden por 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.

10 · 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.

11 · 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.

12 · Un botón es un <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.

13 · El bundle también es rendimiento: importa lo que usas.
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.

14 · Fetch en cascada: pedir A, esperar, pedir B.
// 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:

1 · ServidorbeforeLoad y loader de la ruta corren; llaman a server functions o a la API con secretos que el navegador nunca ve.
2 · ServidorEl componente se ejecuta con esos datos y produce HTML. Sin window, sin efectos, sin estado del navegador.
3 · RedLlega HTML completo (SEO, primer pintado rápido) más los datos serializados del loader.
4 · ClienteHidratación: React vuelve a ejecutar el componente y "adopta" el HTML. Si el resultado difiere → mismatch.
5 · ClienteEfectos, eventos, Query refrescando en segundo plano, navegación sin recarga.

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.

Cuándo NO usar SSR

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:

TanStack Query (y loaders) poseen el estado del servidor
// 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] }) })
Jotai posee el estado de interfaz
// 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/loader de __root.tsx y v1/auth.$token.tsx), no en componentes.
  • Peculiaridades de la casa: enabled: false por defecto en el QueryClient (cada hook decide cuándo puede correr), staleTime 5 min, retry: 3 en queries y 0 en mutaciones, refetchOnWindowFocus: false. El MyRouterContext comparte el QueryClient y 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 viejoSu disfraz en React modernoAsí se hace en la casa nuevaQuién lo frena
Token en localStorage y en la URL (vicio 6)atomWithStorage('token')así está hoy en src/stores/auth-atoms.ts:6-9Cookie HttpOnly vía server function/backend; el navegador no ve el token; el intercambio del token de un solo uso ocurre en servidorRevisió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.pemesa-backend-prod-….run.appVITE_API_BASE_URL por entorno (ya validada en env.ts), dev → staging detrás del LB; en SSR, la URL interna solo en servidorRevisió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 derivadosRevisión con la metodología en la mano
Componentes de 6.427 líneas (vicio 15)Un archivo de ruta con toda la pantalla dentroRuta delgada + componentes por sección + hooks; docs/components-map.mdRevisión (>300 líneas = partir)
Copiar el componente y cambiar diez líneas (vicio 16)WaitListItem/WalkinListItem otra vezUn componente parametrizado por kind; shadcn para lo genéricoRevisión
innerHTML por concatenación (vicio 11)dangerouslySetInnerHTMLJSX; HTML de tenant sanitizado en servidor y en un componente con nombreESLint react/no-danger, revisión
userType > 8 en 65 sitios (vicio 18)if (user.role > 8) dispersoPermisos del backend en un átomo derivado can('editar_reserva'); la UI oculta, el servidor autorizaRevisión
moment.tz.guess(), moment.locale('es') ×21, formatos literales ×57 (vicio 4)new Date() en render; DateTime.local() sin zonaLuxon con la zona del local en un helper; formatos en constantes; fecha del servidorRevisión, tests
77 console.log, alert() (vicio 22)IgualSentry + notificaciones de la app; no-console: errorESLint en CI
Sin tests, jest roto, lint desactivado en build (vicio 20)// eslint-disable masivo; tests que solo hacen snapshotVitest + Testing Library por comportamiento; pnpm check y pnpm test en CI antes de deployCI (pendiente: m-f-libro no tiene .github/)
build/ versionado, build en la laptop, updates.txt (vicio 21)dist/ commiteado; pnpm deploy desde una laptopWrangler desde CI con wrangler.toml versionado; versión por SHA (build:version)CI
Accesibilidad: div onClick, href="#", sin altIgualshadcn/Radix, button, label, alt; eslint-plugin-jsx-a11yESLint
Bundle de 822 KB en gitImportar librerías enteras; sin code splittingImports específicos, chunk por ruta, Konva solo donde se usa; vite build reporta tamañosRevisión del build
m-f-libro

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, siempre
pnpm 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)
Tres cosas que rompen la primera semana

(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.

src/stores/reservations-atoms.ts (agregar)
import { atom } from 'jotai'
export const selectedZoneIdAtom = atom<number | null>(null)          // estado de UI, no persistido
src/hooks/queries/use-get-reservations.ts (ya existe; la clave incluye los filtros)
export 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 },
  )
}
src/components/reservations/ReservationList.tsx
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>
  )
}
src/components/reservations/__tests__/ReservationList.test.tsx
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

src/routes/r.$slug.tsx
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 renderWithProviders en src/test/ que monta QueryClientProvider (con retry: false), Jotai Provider con store limpio, i18n y router de prueba. Cada test parte de cero.
  • Hooks con renderHook; átomos derivados con createStore() y store.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 check y pnpm test limpios; TypeScript sin any/as/! nuevos sin justificar.
  • Datos del servidor en Query (hook en hooks/queries o mutations); ningún axios en componentes; ningún useEffect que espeje queries en átomos.
  • Estado de UI en átomos con nombre en stores/; derivados con atom(get => …).
  • Ningún token, credencial ni URL de servicio en el código, en localStorage nuevo ni en la URL; config por env.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/key en su sitio; sin dangerouslySetInnerHTML sin 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.
Ruta

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:

  1. Pide la traducción con el archivo viejo al lado.

    "Este es ReservationListItem.jsx del libro viejo. Rehazlo como componentes de función siguiendo docs/state-management-methodology.md: qué va a Query, qué a Jotai, qué se parte. Primero el plan, sin código."

  2. 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."

  3. 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.

Así noGuarda 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.
Así sí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 en __root.tsx y v1/auth.$token.tsx. Trade-offs con el prerender actual.Restricción dicha antes; decisión tuya.

Recursos, pocos y elegidos