React en cadences.app: Anatomía de una SPA sin Abstracciones
80+ componentes, un editor de workflows SVG custom, formularios schema-driven, tablas de datos con inline editing — todo en React 19, sin React Router, sin Redux, sin Formik, sin TanStack Table. La historia de las decisiones técnicas detrás de cadences.app.
cadences.app · Plataforma SaaS de Cadences
1 La decisión "custom everything"
cadences.app es un SaaS que combina gestión de proyectos, CRM, workflows no-code, tablas de datos, IA conversacional, dashboards con Chart.js y un motor de ML in-browser. La tentación natural es llenar el package.json con 40 dependencias — React Router para routing, Redux para estado, TanStack Table para tablas, React Flow para workflows, Formik para formularios, shadcn para UI.
La decisión fue la opuesta: construir cada capa desde cero. No por purismo, sino porque cada librería de abstracción impone su modelo mental. Cuando tu editor de workflows necesita pinch-to-zoom en SVG con ejecución en vivo, React Flow se queda corto. Cuando tus formularios se generan dinámicamente desde un schema que el usuario define, Formik estorba más de lo que ayuda.
📊 cadences.app en números
Lo que NO usamos
| Capa | Lo "normal" | Lo que hacemos |
|---|---|---|
| Routing | React Router | useState('day') + setCurrentView() |
| Estado global | Redux / Zustand / Jotai | 8 Context Providers + custom hooks |
| Formularios | Formik / react-hook-form | Schema-driven custom con validación |
| Tablas de datos | TanStack Table / AG Grid | DataTableView.jsx custom (1.182 líneas) |
| Editor workflows | React Flow / xyflow | SVG custom + pointer events (3.084 líneas) |
| Data fetching | SWR / React Query / Axios | Custom request() wrapper (2.494 líneas) |
| UI components | shadcn / Radix / MUI | Tailwind CSS + hand-crafted components |
2 Arquitectura de componentes y estructura del proyecto
La app vive en src/ del repo raíz. La estructura es deliberadamente plana para las vistas principales — los 80+ archivos de componentes top-level viven directamente en src/ — y modular para los subsistemas: workflow, admin panel, hooks, contexts, services.
| Directorio | Propósito | Archivos |
|---|---|---|
src/components/ | UI reutilizable: admin panel, modals, TaskModal/, DataTable* | 40+ |
src/hooks/ | Custom hooks: persistencia, sync, paginación, IA, workflows | 23 |
src/contexts/ | React Context providers: Auth, AppState, UI, User, Theme, ML | 6 |
src/services/ | API clients: Gmail, WhatsApp, Google Drive, ElevenLabs, scraper… | 26 |
src/workflow/ | Workflow editor completo: canvas, nodes, edges, execution engine | 25+ |
src/ai/ | IA: streaming, memoria conversacional, sparse JSON parser | 8 |
src/features/ml-trainer/ | ML in-browser: TensorFlow.js, AutoML, Federated Learning | 10+ |
src/voice/ | Audio recording: AudioPlayer, VoiceRecorderPro | 5 |
src/i18n/ | Internacionalización ES/EN con react-i18next | 4 |
¿Por qué la estructura plana? Las vistas principales (WorkflowView.jsx, DataTableView.jsx, KanbanView.jsx, CalendarView.jsx…) funcionan como orchestrators. Cada una coordina sus propios modals, sub-componentes y efectos. Agruparlas en carpetas no añade claridad — al contrario, rompe la navegación rápida con Cmd+P.
3 Patrones React que usamos (y por qué)
Todo es componentes funcionales. Cero clases. React 19 con las APIs más recientes. Los patrones dominantes son los que emergen cuando no usas librerías de abstracción — tienes que resolver los problemas tú mismo.
⚡ React.memo con comparadores custom
El workflow canvas puede tener 50+ nodos SVG. Sin React.memo con comparación shallow custom, cada movimiento del mouse dispararía un re-render de todos los nodos. El comparador solo verifica las props que afectan al renderizado visual.
📦 React.lazy + Suspense centralizado
Todos los modals y vistas pesadas están lazy-loaded desde un archivo centralizado. El usuario solo carga el código del workflow editor cuando abre un workflow, no antes. Esto reduce el bundle inicial un 60%.
🎯 useRef para estado sin re-render
En el canvas del workflow, el estado de drag (posición del mouse, nodo arrastrado, offset) cambia 60 veces por segundo. Si usáramos useState, cada frame sería un re-render. Con useRef, actualizamos las posiciones directamente sin tocar el ciclo de React.
🧠 useMemo para datos derivados
Las posiciones de nodos, los mapas de edges, los filtros de tablas — todo lo que se deriva del estado se wrappea en useMemo. Un workflow con 30 nodos y 50 edges necesita un mapa de posiciones recalculado solo cuando cambian los nodos, no cuando el usuario mueve el mouse.
🧩 Compound components
TaskModal se descompone en TaskModalHeader, TaskModalFooter, TaskModalTabs. Cada pieza es independiente y testeable, pero juntas forman un modal coherente. El patrón se repite en el admin panel: cada sección (LeadsSection, BillingSection, InvoicesSection…) sigue la misma interfaz.
4 Estado global: 8 Context Providers
Sin Redux, sin Zustand, sin Jotai. Solo Context API + custom hooks. La razón: el estado de cadences.app se particiona naturalmente en dominios que no se cruzan — auth no necesita saber del estado de ML, el tema no necesita los datos de usuario. Context funciona perfecto cuando no tienes un solo megastore.
Google OAuth, tiers (FREE/PERSONAL/PRO/BUSINESS), permisos, token refresh, auth state machine.
Proyectos, proyecto seleccionado, vista actual, fecha del calendario, búsqueda, orden, filtros activos.
Estado de todos los modales (open/close), flyouts, confirmaciones, cambios sin guardar. Un solo provider para toda la UI.
Datos del usuario actual y lista global de usuarios para asignaciones y menciones.
Dark/light mode. Persistido en localStorage. El tema oscuro es Apple-inspired: neutral-800/900, indigo-600 accents.
TensorFlow.js models in-browser, estado de training, federated learning, AutoML config.
Toast notifications via react-hot-toast. Centralizado para uso global desde cualquier componente.
i18n con i18next. Detección automática del idioma del navegador. ES/EN completo.
5 Formularios schema-driven: sin Formik, sin react-hook-form
Los formularios de cadences.app no son estáticos — los define el usuario. Un proyecto de tipo DATA_TABLE permite crear campos custom (texto, número, fecha, select, toggle, referencia a otra tabla), organizarlos en pasos multi-page, configurar validación, y publicar el formulario como URL pública embeddable. Formik o react-hook-form no están diseñados para esto.
DataFormModal
1.140 líneas. Renderiza formularios desde formDefinitions + dataSchema. Multi-step con navegación entre pages. Validación custom por campo. Error summary modal.
FormDefinitionsEditorModal
1.894 líneas. Editor visual de formularios — drag para reordenar campos, configurar tipos, añadir validación. Generación asistida por IA (Gemini analiza el contexto y sugiere campos).
PublicFormPage
493 líneas. Formulario público anónimo en /form/:projectId. Sin auth required. Embed via iframe. Los datos van directo a la tabla del proyecto.
DataReferenceSelectPro
Selector relacional — campos que referencian filas de otras tablas (foreign keys). Búsqueda inline, creación rápida, preview del registro referenciado.
6 Editor de Workflows: SVG custom, cero dependencias
El editor no-code de workflows es probablemente la pieza más compleja de la app. Un canvas SVG interactivo con drag-and-drop de nodos, edges Bézier, zoom/pan, pinch-to-zoom en móvil, ejecución en vivo con visualización de estado, debug step-by-step con inspector de variables, y 18 tipos de nodos con sus editores propios.
WorkflowView.jsx — 5.090 líneas
Orchestrator. Gestiona modales, ejecución, scheduling, permisos, state machine completa del workflow.
WorkflowCanvas.jsx — 3.084 líneas
Canvas SVG puro. Pointer events (no HTML DnD). Touch con pinch-to-zoom. Long-press context menu. Throttled rendering. Auto-layout con undo.
WorkflowNode.jsx — React.memo
SVG <g> group memoizado. Custom comparator. Render condicional de iconos de estado, badges, connection points.
WorkflowEdge.jsx — Curvas Bézier
SVG <path> con curvas Bézier. Labels en edges. Click areas invisibles para selección fácil.
workflowExecutionEngine.js — State machine
Motor de ejecución: resolve dependencies, execute nodes secuencialmente/en paralelo, handle conditions/loops. Adapter pattern para local vs backend execution.
18 tipos de nodos
🎨 ¿Por qué no React Flow?
React Flow es excelente para editores de flujo genéricos. Pero cuando necesitas ejecución en vivo con nodos que flashean colores según su estado, debug step-forward, inspector de variables, pinch-to-zoom nativo en móvil, y long-press context menus — terminas sobrescribiendo el 80% de React Flow. Es más limpio empezar desde SVG + pointer events.
7 Tablas de datos: inline editing y dashboard con IA
Las tablas no son solo visualización — son el CRM. Un proyecto de tipo DATA_TABLE es una base de datos visual con inline editing, paginación client-side, filtros multi-columna, imports/exports, y un dashboard analítico con Chart.js. Todo custom.
DataTableView.jsx
1.182 líneas. Tabla principal con columnas generadas desde dataSchema. Inline editing por celda. Column visibility toggle. Responsive detection para modo mobile-card.
DataTableDashboard.jsx
1.961 líneas. Dashboards analíticos auto-generados: Bar, Doughnut, Line, Scatter, Bubble charts. KPI cards. AI insights sobre los datos.
useDataTablePagination
459 líneas. Hook para paginación (25/50/100/200/500), búsqueda debounced (300ms), sorting multi-columna. Soporta IndexedDB y backend API.
MLInsightsPanel.jsx
Panel de insights ML sobre los datos de la tabla. TensorFlow.js in-browser para predicciones y anomalías. Federated Learning para entrenar sin enviar datos al servidor.
8 Admin Panel: 20 secciones, un patrón
El admin panel tiene 20 secciones — desde leads CRM hasta configuración de proveedores de IA. Todas siguen el mismo patrón CRUD: useState para loading/data, useEffect para fetch inicial, tabla con search/filter, modal para create/edit. La consistencia no es accidental — es el resultado de no usar un framework de admin que imponga su propia abstracción.
9 Persistencia offline-first: el hook de 4.750 líneas
useDataPersistenceIndexedDB es el hook más complejo de la app. 4.750 líneas que manejan la capa de datos completa:
Los datos se serializan, comprimen con pako (gzip), y se suben a un archivo JSON en Google Drive. Sync bidireccional con resolución de conflictos.
Para tier PRO, los datos también se sincronizan con el backend (D1 database). Prioridad: Local → Drive → API.
Cuando el usuario edita en dos dispositivos, los vector clocks determinan cuál es la versión más reciente sin perder datos. Merging field-level.
Las operaciones de escritura en offline se encolan. Cuando vuelve la conexión, se ejecutan en orden con retry exponencial.
10 El stack completo y las dependencias que sí usamos
No todo es custom. Las librerías que elegimos son las que hacen una sola cosa y la hacen bien — sin imponer arquitectura.
| Dependencia | Versión | Por qué |
|---|---|---|
| react / react-dom | ^19.1.0 | React 19 — últimas APIs, Concurrent features |
| dexie | ^4.2.1 | ORM para IndexedDB. 11-version schema migrations |
| @dnd-kit/* | ^6/^8/^3 | Drag-and-drop para Kanban board (no para workflows) |
| chart.js + react-chartjs-2 | ^4.5 / ^5.3 | Visualización: Bar, Doughnut, Line, Scatter, Bubble |
| lucide-react | ^0.511 | Iconos. 100+ por archivo. Coherencia visual total |
| i18next + react-i18next | ^25.7 / ^16.5 | Internacionalización ES/EN completa |
| pako | ^2.1.0 | Gzip compression para sync data |
| @xenova/transformers | ^2.17.2 | ML inference in-browser (Hugging Face) |
| tailwindcss | ^3.4.17 | Todo el CSS. Dark mode con dark: variants |
| vite | ^6.3.5 | Build tool. HMR instantáneo, tree-shaking |
Nota sobre TypeScript: La app principal es JavaScript con JSDoc-style typing. Los @types/react están en devDependencies para IntelliSense. Los storefronts (Codex, CimAD, GoViajes) sí usan TypeScript con tsconfig.json. ¿Por qué? Porque la app principal se inició cuando los tooling de TS para Vite + React era menos maduro. Los tipos viven en src/types/auth.js (807 líneas) y src/models/index.js (483 líneas) como constantes y objetos tipados por convención.
11 Routing sin React Router
cadences.app no tiene "páginas" en el sentido tradicional. Es una SPA pura — un solo HTML, un solo bundle, y un currentView que determina qué se renderiza. La navegación es un useState que persiste en localStorage.
¿No se pierde el back button? No aplica. cadences.app es una app de productividad, no un sitio web. El usuario no navega "atrás" — cambia de vista. El último view se persiste en localStorage. Los formularios públicos (/form/:projectId) sí usan window.location.pathname porque son rutas reales que se comparten por URL.
12 Lo que aprendimos construyendo sin abstracciones
1. useRef es tu mejor amigo en interacciones de 60fps
Cada vez que algo se mueve a 60fps — drag, resize, scroll, gestos táctiles — el estado debe vivir en refs, no en state. useState dispara re-renders. En un canvas SVG con 50 nodos, eso es la diferencia entre smooth y laggy.
2. React.memo sin comparador custom es casi inútil
La comparación shallow por defecto falla con objetos y arrays. Si tu componente recibe node como prop, shallow compare dará siempre false si el parent recreó el objeto. El comparador custom que solo verifica las propiedades visuales relevantes es obligatorio.
3. Context funciona si particionas bien
El anti-pattern de Context es meter todo en un solo provider gigante. Nuestros 8 providers están particionados por dominio. Un cambio de tema no re-renderiza el auth. Un cambio de proyecto no re-renderiza el ML. La clave es que cada contexto sea una sola responsabilidad.
4. React.lazy + Suspense escala mejor de lo que pensabas
Centralizar los lazy imports en un solo archivo (lazyComponents.jsx) hace que sea trivial añadir nuevas vistas sin tocar routing. Cada vista se carga bajo demanda. El bundle inicial <200KB. El workflow editor (3.000+ líneas) solo se descarga cuando el usuario abre un workflow.
5. La API layer custom te da control total sobre auth y retry
Nuestro request() wrapper (2.494 líneas) maneja auth tokens, customer IDs, visual sync tracking para writes, retry con backoff, y error handling custom. SWR o React Query habrían requerido igualmente un wrapper — y habrían impuesto su cache invalidation model encima del nuestro (que ya tiene IndexedDB + Drive + API sync).
📋 cadences.app en cifras
Cadences Engineering
Documentación técnica del equipo de ingeniería