← Blog

React como capa de UI: separar la lógica de negocio sin perder los hooks

React como capa de UI — separar lógica de negocio con fachadas y dominio puro

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ógicaDónde encaja mejorMotivo
Abrir y cerrar un modalHook o componente ReactEs estado efímero de UI y depende del árbol visual.
Medir el tamaño de la ventanaHook ReactSincroniza con APIs del navegador y ciclo de vida del componente.
Foco automático y accesibilidadHook ReactEstá ligado al DOM y a la presentación.
Reglas de validación de negocioFuera de ReactDeben poder probarse sin renderizar componentes.
Flujo de un wizard de varios pasosFachada o caso de uso fuera de ReactCambia con el negocio y no debería depender del render.
Guardado, reintentos y coordinación con serviciosFuera de ReactSon políticas de aplicación más que comportamiento visual.
Suscripción a un store externoHook fino con useSyncExternalStoreReact 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:

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:

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.

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:

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:

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.