← Blog | Plataforma 20 Dic 2025 · 20 min lectura

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 dependenciasReact 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

80+ componentes React
23 custom hooks
8 Context Providers
5.268 líneas en App.jsx

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.

const WorkflowNode = React.memo(({ node, position, isSelected, ... }) => { return ( <g transform={`translate(${pos.x},${pos.y})`}> <rect rx={8} fill={style.background} stroke={...} /> /* status icon, label, connection points */ </g> ); }, (prev, next) => /* shallow compare only visual props */ prev.node.label === next.node.label && prev.isSelected === next.isSelected && prev.position?.x === next.position?.x && prev.position?.y === next.position?.y );

📦 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%.

// lazyComponents.jsx — punto central de lazy loading export const WorkflowView = lazy(() => import('./WorkflowView')); export const DataFormModal = lazy(() => import('./DataFormModal')); export const AdminPanel = lazy(() => import('./AdminPanel')); export const MLTrainerView = lazy(() => import('./features/ml-trainer/MLTrainerView')); // ... 20+ lazy imports // En MainContent.jsx <Suspense fallback={<LoadingSpinner />}> {currentView === 'workflow' && <WorkflowView />} </Suspense>

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

const dragStateRef = useRef({ isDragging: false, nodeId: null, startX: 0, startY: 0, offsetX: 0, offsetY: 0 }); const touchGestureRef = useRef({ initialDistance: 0, initialScale: 1, isPinching: false }); // onPointerMove: update ref directly, transform SVG group // Only setState when drag ENDS to persist final position

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

🔐 AuthProvider 1.554 líneas

Google OAuth, tiers (FREE/PERSONAL/PRO/BUSINESS), permisos, token refresh, auth state machine.

📋 AppStateProvider

Proyectos, proyecto seleccionado, vista actual, fecha del calendario, búsqueda, orden, filtros activos.

🎨 UIProvider 244 líneas

Estado de todos los modales (open/close), flyouts, confirmaciones, cambios sin guardar. Un solo provider para toda la UI.

👤 UserProvider

Datos del usuario actual y lista global de usuarios para asignaciones y menciones.

🌙 ThemeProvider

Dark/light mode. Persistido en localStorage. El tema oscuro es Apple-inspired: neutral-800/900, indigo-600 accents.

🧪 MLProvider 514 líneas

TensorFlow.js models in-browser, estado de training, federated learning, AutoML config.

🔔 NotificationProvider

Toast notifications via react-hot-toast. Centralizado para uso global desde cualquier componente.

🌐 LanguageProvider

i18n con i18next. Detección automática del idioma del navegador. ES/EN completo.

// App.jsx — nesting natural de providers <ThemeProvider> <LanguageProvider> <AuthProvider> <UserProvider> <AppStateProvider> <UIProvider> <MLProvider> <NotificationProvider> <MainContent /> </NotificationProvider> </MLProvider> </UIProvider> </AppStateProvider> </UserProvider> </AuthProvider> </LanguageProvider> </ThemeProvider>

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.

// Patrón de estado de formularios — sin librería const [formData, setFormData] = useState({}); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const [currentFormIndex, setCurrentFormIndex] = useState(0); // Los campos vienen del schema del proyecto project.dataSchema.forEach(field => { // Renderiza el input correcto según field.type // text | number | date | select | toggle | reference // Aplica validación según field.validation });

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

🤖 AI Agent
🔍 Data Query
⚡ Condition
🔄 Loop
⏰ Trigger
📅 Scheduled Start
📧 Email
💬 WhatsApp Agent
🔗 Integration
🏥 FHIR
🗄️ SQL Query
📁 Google Drive
⚙️ Workflow Executor
📝 Transform
🌐 HTTP Request
📊 Analytics
🔔 Notification
🛑 End

🎨 ¿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.

📊 Leads
💳 Billing
🏢 Organizations
🎟️ Coupons
📄 Invoices
⏳ Trials
🚫 Bans
📧 Emails
📰 Newsletters
🤖 Chatbots
🧠 AI Providers
⚡ Workers AI
🍽️ Menu Templates
🔑 API Keys
💬 Chat Sessions
📊 NutriNen
// Patrón CRUD que se repite en las 20 secciones const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [editItem, setEditItem] = useState(null); useEffect(() => { api.get('/admin/leads').then(setData).finally(() => setLoading(false)); }, []); const filtered = data.filter(item => item.name.toLowerCase().includes(search.toLowerCase()) ); // → tabla + search bar + modal de edició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:

STORAGE
IndexedDB via Dexie.js

11-version schema con migraciones. Todos los datos del usuario viven en el navegador. La app funciona 100% offline.

SYNC
Google Drive sync (STANDARD+)

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.

API
Backend API sync (PRO)

Para tier PRO, los datos también se sincronizan con el backend (D1 database). Prioridad: Local → Drive → API.

CRDT
Vector clocks para conflictos

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.

QUEUE
Offline queue con auto-retry

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.

const [currentView, setCurrentView] = useState('day'); // 13 vistas disponibles // 'kanban' | 'list' | 'day' | 'week' | 'month' | 'calendar' // 'workflow' | 'table' | 'overdue' | 'monitor' // 'timeline' | 'gantt' | 'epoch' // Tipo de proyecto → vista forzada if (project.type === 'data_table') setCurrentView('table'); if (project.type === 'workflow') setCurrentView('workflow'); // MainContent.jsx renderiza según currentView {currentView === 'kanban' && <KanbanView />} {currentView === 'workflow' && <WorkflowView />} {currentView === 'table' && <DataTableView />} // ...

¿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

80+ componentes
23 custom hooks
26 servicios API
18 tipos de nodo workflow
20 secciones admin
13 vistas disponibles
4.750 líneas en persistence hook
0 librerías de abstracción
C

Cadences Engineering

Documentación técnica del equipo de ingeniería