React como capa de UI: separar la lógica de negocio sin perder los hooks
React como capa de UI: separar la lógica de negocio sin perder los hooks
React no pierde sentido cuando se usa principalmente para pintar interfaces. En aplicaciones con reglas de negocio complejas, ese enfoque suele mejorar la claridad, la testabilidad y la capacidad de cambiar una feature sin reescribir media UI.
La clave no es “prohibir” hooks, sino decidir qué tipo de lógica debe vivir dentro de React y cuál debería existir fuera, en TypeScript normal, para que pueda probarse y evolucionar sin depender del ciclo de render.
El problema habitual
En muchas aplicaciones React, la lógica de negocio acaba repartida entre componentes, custom hooks, efectos, handlers y llamadas a servicios. Al principio parece cómodo, pero con el tiempo se vuelve más difícil entender qué hace cada parte, probar reglas concretas y modificar flujos sin romper la interfaz.
Ese problema aparece especialmente en features con varios pasos, validaciones, estados compartidos, guardado asíncrono y gestión de errores. Cuanto más negocio se mete dentro de useEffect, useState o custom hooks grandes, más se acopla la feature al runtime de React.
La idea central
La propuesta consiste en tratar React como la capa de presentación y mover fuera la lógica que podría ejecutarse también en otro entorno: tests, servidor, CLI o incluso otra librería de UI. React sigue siendo útil para componer componentes, gestionar estado visual y sincronizar la interfaz con sistemas externos, pero deja de ser el lugar donde vive el corazón del negocio.
Esto no elimina los hooks. Lo que cambia es su papel: pasan de contener la lógica principal a actuar como adaptadores entre la UI y un motor externo, o a resolver preocupaciones puramente visuales como foco, animaciones, listeners del DOM y estado efímero de interfaz.
Qué lógica va en React y cuál no
| Tipo de lógica | Dónde encaja mejor | Motivo |
|---|---|---|
| Abrir y cerrar un modal | Hook o componente React | Es estado efímero de UI y depende del árbol visual. |
| Medir el tamaño de la ventana | Hook React | Sincroniza con APIs del navegador y ciclo de vida del componente. |
| Foco automático y accesibilidad | Hook React | Está ligado al DOM y a la presentación. |
| Reglas de validación de negocio | Fuera de React | Deben poder probarse sin renderizar componentes. |
| Flujo de un wizard de varios pasos | Fachada o caso de uso fuera de React | Cambia con el negocio y no debería depender del render. |
| Guardado, reintentos y coordinación con servicios | Fuera de React | Son políticas de aplicación más que comportamiento visual. |
| Suscripción a un store externo | Hook fino con useSyncExternalStore | React actúa como puente hacia un estado externo estable. |
Caso real: onboarding de perfil
Un caso claro es un onboarding de perfil con tres pasos: datos personales, preferencias y confirmación. A nivel visual hay formularios, barra de progreso y botones de siguiente o atrás; a nivel de negocio hay validaciones, navegación entre pasos, persistencia, estado de guardado y manejo de errores.
Si toda esa lógica vive en el componente, el archivo crece muy rápido: aparecen varios useState, varios useEffect, callbacks enlazados y condiciones repartidas entre JSX y handlers. Si el flujo cambia, la UI y el negocio cambian a la vez, y probar solo las reglas se vuelve más costoso.
Una alternativa más mantenible es dividir la feature en estas piezas:
domain/: tipos, reglas y validaciones.application/ofacade/: API pública de la feature.store/: estado interno y suscripciones.infra/: API, persistencia, analytics o dependencias externas.ui/: componentes React que leen estado y disparan acciones.
Ejemplo de implementación
1. Dominio
// domain/profile-setup.ts
export type Answers = {
name?: string;
age?: number;
likesMusic?: boolean;
};
export function validateStep(step: number, answers: Answers): string | null {
if (step === 0 && !answers.name) return "El nombre es obligatorio";
if (step === 1 && typeof answers.likesMusic !== "boolean") {
return "Debes indicar si te gusta la música";
}
return null;
}
Aquí no hay React. Solo hay reglas que pueden ejecutarse en cualquier entorno y probarse con tests normales de TypeScript.
2. Fachada
// application/createProfileSetupFacade.ts
import { validateStep, type Answers } from "../domain/profile-setup";
type State = {
step: number;
answers: Answers;
saving: boolean;
saved: boolean;
error: string | null;
};
type Deps = {
saveProfile: (answers: Answers) => Promise<void>;
store: {
getState: () => State;
setState: (next: State) => void;
subscribe: (listener: () => void) => () => void;
};
};
export function createProfileSetupFacade(deps: Deps) {
const getState = () => deps.store.getState();
return {
getState,
subscribe: deps.store.subscribe,
next(partial: Answers) {
const current = getState();
const answers = { ...current.answers, ...partial };
const error = validateStep(current.step, answers);
if (error) {
deps.store.setState({ ...current, answers, error });
return;
}
deps.store.setState({
...current,
answers,
step: current.step + 1,
error: null,
});
},
prev() {
const current = getState();
deps.store.setState({
...current,
step: Math.max(0, current.step - 1),
error: null,
});
},
async save() {
const current = getState();
deps.store.setState({ ...current, saving: true, error: null });
try {
await deps.saveProfile(getState().answers);
deps.store.setState({
...getState(),
saving: false,
saved: true,
});
} catch {
deps.store.setState({
...getState(),
saving: false,
error: "No se pudo guardar el perfil",
});
}
},
};
}
La fachada expone una API pública simple para la UI: avanzar, retroceder, guardar y leer estado. La pantalla no necesita conocer los detalles de validación, persistencia o transición entre pasos.
3. Hook de adaptación
// ui/useProfileSetup.ts
import { useSyncExternalStore } from "react";
export function useProfileSetup(facade: {
getState: () => any;
subscribe: (listener: () => void) => () => void;
next: (answers: any) => void;
prev: () => void;
save: () => Promise<void>;
}) {
const state = useSyncExternalStore(
facade.subscribe,
facade.getState,
facade.getState,
);
return {
state,
next: facade.next,
prev: facade.prev,
save: facade.save,
};
}
Este hook sí usa React, pero no contiene el negocio. Su trabajo es adaptar el estado externo al sistema de render de React, algo para lo que useSyncExternalStore está diseñado de forma explícita.
4. Componente React
// ui/ProfileSetupScreen.tsx
export function ProfileSetupScreen({ facade }: { facade: any }) {
const { state, next, prev, save } = useProfileSetup(facade);
return (
<section>
<h1>Configura tu perfil</h1>
<p>Paso {state.step + 1} de 3</p>
{state.step === 0 && (
<button onClick={() => next({ name: "Lucía" })}>Continuar</button>
)}
{state.step === 1 && (
<button onClick={() => next({ likesMusic: true })}>Continuar</button>
)}
{state.step === 2 && <button onClick={save}>Guardar</button>}
{state.step > 0 && <button onClick={prev}>Atrás</button>}
{state.error && <p role="alert">{state.error}</p>}
{state.saving && <p>Guardando…</p>}
{state.saved && <p>Perfil guardado</p>}
</section>
);
}
El componente queda centrado en renderizar y reaccionar a eventos de interfaz. Esa es precisamente la parte donde React sigue brillando: composición declarativa, árbol de componentes y conexión cómoda entre estado visual y UI.
Variante: una fachada orientada a React
Hasta aquí se ha usado una fachada relativamente neutra: expone acciones como next, prev o save, y deja que React se conecte al estado mediante un hook adaptador. Ese enfoque hace más visible la frontera entre el negocio y el framework, pero no es la única forma de organizar la feature.
Otra opción es construir una fachada más orientada al consumo desde React. En lugar de exponer solo comandos y mecanismos genéricos como getState o subscribe, esta variante publica una API preparada para la capa de presentación: acciones por un lado y lecturas específicas por otro.
// application/createProfileSetupFacade.ts
import type { Answers } from "../domain/profile-setup";
export function createProfileSetupFacade(store: Store, trigger: Trigger) {
return {
init: () => trigger("INIT"),
start: () => trigger("START"),
prev: () => trigger("PREV"),
next: (payload: Answers) => trigger("NEXT", payload),
save: () => trigger("SAVE"),
useIsLoading: () => store.$isLoading.use(),
useStep: () => store.$step.use(),
useAnswers: () => store.$answers.use(),
useError: () => store.$error.use(),
useIsSaved: () => store.$isSaved.use(),
};
}
Aquí la fachada actúa como una API pública cerrada para toda la feature. El componente no conoce el store interno, no sabe qué eventos existen realmente y tampoco depende de cómo se calculan los selectores; simplemente consume métodos de alto nivel.
export function ProfileSetupScreen({
facade,
}: {
facade: ReturnType<typeof createProfileSetupFacade>;
}) {
const step = facade.useStep();
const error = facade.useError();
const isLoading = facade.useIsLoading();
const isSaved = facade.useIsSaved();
return (
<section>
<h1>Configura tu perfil</h1>
{step === 0 && (
<button onClick={() => facade.next({ name: "Lucía" })}>
Continuar
</button>
)}
{step > 0 && <button onClick={facade.prev}>Atrás</button>}
<button onClick={facade.save} disabled={isLoading}>
Guardar
</button>
{error && <p role="alert">{error}</p>}
{isSaved && <p>Perfil guardado</p>}
</section>
);
}
Esta forma tiene una ventaja clara: la UI queda todavía más cómoda de consumir. Cada feature se comporta como un módulo autocontenido con una superficie pública uniforme, algo especialmente útil cuando se quiere estandarizar cómo se construyen módulos o cuando varios desarrolladores trabajan con el mismo patrón.
Aun así, hay un matiz importante. Esta fachada sigue ocultando muy bien los detalles internos, pero su API pública ya está más pensada para React que para un consumidor completamente agnóstico. No depende necesariamente de React por dentro, pero sí adopta una forma de consumo muy cercana al framework.
Cuándo usar esta variante
Esta versión suele encajar mejor cuando se prioriza la ergonomía de uso dentro de una codebase React y se quiere que cada feature exponga siempre el mismo tipo de interfaz.
Puede ser una buena opción si se busca:
- Una API muy cómoda para la capa de presentación.
- Menos decisiones en los componentes.
- Módulos con una superficie pública estable y fácil de descubrir.
- Consistencia entre features similares.
En cambio, si el objetivo principal es mantener el núcleo lo más portable posible o dejar muy marcada la frontera entre framework y negocio, la combinación de fachada neutra más hook adaptador suele comunicar mejor esa separación.
Cómo encaja con el ejemplo anterior
Las dos opciones son compatibles con la misma filosofía. La diferencia no está en si existe o no una separación de capas, sino en dónde se coloca la última adaptación a React.
- En la versión anterior, la adaptación ocurre en
useProfileSetup. - En esta variante, parte de esa adaptación se integra directamente en la fachada.
Dicho de otra forma: no cambia el objetivo arquitectónico, cambia el diseño de la API pública de la feature.
Qué se gana
Este enfoque aporta varias ventajas cuando la feature tiene complejidad real:
- Las reglas de negocio se prueban sin montar componentes ni usar
renderHook. - La UI queda más simple y suele ser más fácil de leer.
- Cambiar React por otra capa de presentación se vuelve menos costoso, porque el núcleo ya no depende de hooks ni del ciclo de render.
- Los custom hooks dejan de ser “mini frameworks ocultos” y se convierten en adaptadores pequeños y predecibles.
También hay un beneficio menos obvio: obliga a nombrar mejor las piezas del sistema. Cuando una feature tiene facade, domain, store e infra, resulta más claro dónde debería ir cada cambio y qué parte se está rompiendo cuando un test falla.
Qué no se debería hacer por dogma
No todo necesita este nivel de separación. En un formulario simple o una UI pequeña, meter una arquitectura así puede añadir ceremonia innecesaria y ralentizar el desarrollo.
Tampoco significa que los hooks solo sirvan para modales. Los hooks siguen siendo muy útiles para sincronización con el navegador, accesibilidad, animaciones, estado efímero y adaptación a stores externos; lo que conviene evitar es usarlos como contenedor principal del negocio de la aplicación.
Cuándo merece la pena
Este patrón empieza a compensar cuando aparecen señales como estas:
- Componentes con demasiados
useEffecty ramas condicionales. - Custom hooks enormes que mezclan fetch, validación, navegación y transformación de datos.
- Dificultad para probar reglas sin renderizar la pantalla completa.
- Features que comparten lógica entre varias vistas.
- Cambios frecuentes en flujos, pasos o reglas de negocio.
Si la feature solo necesita estado local simple, probablemente useState y un par de hooks bien puestos sean suficientes. Si la feature se parece más a un pequeño sistema dentro de la app, separar el motor de la interfaz suele ser una decisión razonable.
Cierre
Usar React principalmente como capa de UI no es una renuncia, sino una forma de limitar su responsabilidad. React sigue manejando la presentación y la sincronización visual, mientras el negocio vive en piezas más fáciles de probar, reutilizar y mantener.
La pregunta útil no es si un proyecto “aprovecha todo React”, sino si cada tipo de lógica está viviendo en el sitio correcto. Cuando esa frontera está clara, la aplicación suele volverse más estable y mucho más fácil de evolucionar.