UI layer
UI — Components & Pages
Responsabilidad
La capa de UI se encarga exclusivamente de la experiencia de usuario:
- Renderizar componentes
- Manejar estado visual (open/close, loading local, tabs)
- Escuchar eventos del usuario
- Mostrar resultados (success / error / empty states)
Qué NO hace
- ❌ No contiene lógica de negocio
- ❌ No valida reglas del dominio
- ❌ No transforma datos del backend
- ❌ No conoce DTOs ni estructuras del API
ui/
├── page/
├── widgets/
├── components/
├── hooks/ # opcional — hooks de UI locales al feature
└── context/ # opcional — contextos de React locales al feature
Tipos de archivos en UI
1. Pages
Qué son
Componentes de alto nivel que representan una ruta o vista principal.
Una Page construye la pantalla combinando varios Widgets
Responsabilidad
- Componer widgets y components
- Conectar la UI con hooks / use-cases
- Manejar estados de carga y error
Fetch en Page solo cuando la data se comparte
Por defecto, cada Widget hace su propia query. Solo considera poner una query en la Page cuando:
2+ widgets comparten la misma petición, o
necesitas cargar un recurso “root” para saber si renderizar la pantalla (ej: user no existe).
✅ Ejemplo: el user se usa en 3 widgets → la Page puede hacer useUserQuery() una vez.
Evita bajar props innecesarias por muchos niveles desde el Page
✅ Regla práctica:
Si un parámetro debe bajar más de 3 componentes, considera usar Context.
Ejemplo
// ui/page/UserDashboardPage.tsx
export function UserDashboardPage({ userId }: { userId: string }) {
// ✅ Query en Page porque el "user" se comparte en varios widgets
const userQuery = useUserQuery(userId);
// Estado global (root): si no hay user, no tiene sentido renderizar dashboard
if (userQuery.isLoading) return <PageSkeleton />;
if (userQuery.error) return <ErrorState message="Could not load user" />;
if (!userQuery.data) return <EmptyState message="User not found" />;
const user = userQuery.data;
return (
<div className="space-y-6">
{/* Widgets reciben el user compartido */}
<UserHeaderWidget user={user} />
<UserProfileWidget user={user} />
{/* Widgets con queries propias: loading independiente */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<UserActivityWidget userId={userId} />
<UserPermissionsWidget userId={userId} />
</div>
<UserMetaWidget user={user} />
</div>
);
}
2. Widgets
Qué son
Bloques de UI más complejos, con composición interna.
Cada Widget debe poder:
-
renderizar su propio skeleton
-
mostrar su propio error
-
manejar su propio empty state
Esto evita que:
-
toda la pantalla quede bloqueada por una sección
-
un error parcial rompa toda la vista
✅ Regla práctica:
-Un Widget debe poder “vivir” aunque los otros widgets estén cargando o fallen.
Responsabilidad
-
Agrupar varios components
-
Manejar estado visual local
-
Encapsular comportamiento UI
Ejemplo
// ui/widgets/UserActivityWidget.tsx
export function UserActivityWidget({ userId }: { userId: string }) {
const { data, isLoading, error } = useUserActivityQuery(userId);
if (isLoading) return <CardSkeleton title="Recent activity" />;
if (error) {
return (
<Card>
<CardHeader>
<CardTitle>Recent activity</CardTitle>
</CardHeader>
<CardContent>
<InlineError message="Could not load activity" />
</CardContent>
</Card>
);
}
if (!data?.length) {
return (
<Card>
<CardHeader>
<CardTitle>Recent activity</CardTitle>
</CardHeader>
<CardContent>
<EmptyState message="No activity yet" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent activity</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{data.map((item) => (
<li key={item.id} className="text-sm">
{item.label}
</li>
))}
</ul>
</CardContent>
</Card>
);
}
3. Components
Qué son
Componentes reutilizables enfocados en UI.
Responsabilidad
-
Renderizar datos
-
Emitir eventos (onClick, onSubmit)
-
Recibir props claras
Ejemplo
// ui/components/StatusBadge.tsx
export function StatusBadge({ status }: { status: 'ACTIVE' | 'BLOCKED' }) {
return (
<Badge variant={status === 'ACTIVE' ? 'success' : 'destructive'}>
{status}
</Badge>
);
}
📌 Observa que:
-
La UI no sabe cómo se obtiene el usuario
-
La UI no sabe cómo es la respuesta del API
-
Solo renderiza según el estado
4. Hooks
Qué son
Custom hooks que encapsulan lógica de UI reutilizable y autocontenida — sin queries, sin mutaciones, sin lógica de negocio.
Responsabilidad
- Extraer lógica de estado visual repetida de los componentes
- Agrupar estado relacionado, valores derivados y acciones en una sola interfaz
Qué NO va aquí
- ❌ Queries ni mutations (eso es Application)
- ❌ Lógica de negocio
- ❌ Estado que necesita compartirse entre componentes (eso es Context)
Ejemplo
// ui/hooks/useDisclosure.ts
export function useDisclosure(initialState = false) {
const [isOpen, setIsOpen] = React.useState(initialState);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((v) => !v),
};
}
Cómo se usa
// ui/widgets/DeleteUserWidget.tsx
export function DeleteUserWidget({ userId }: { userId: string }) {
const dialog = useDisclosure();
const deleteUser = useDeleteUser();
return (
<React.Fragment>
<Button variant="destructive" onClick={dialog.open}>
Eliminar usuario
</Button>
<ConfirmDialog
open={dialog.isOpen}
onConfirm={() => deleteUser.mutateAsync(userId)}
onCancel={dialog.close}
/>
</React.Fragment>
);
}
5. Context
Qué es
Contextos de React que combinan estado y acciones para compartirlos entre varios componentes de un feature sin prop drilling.
Responsabilidad
- Compartir estado visual que múltiples widgets o components del feature necesitan
- Centralizar acciones relacionadas con ese estado
- Evitar pasar props por más de 3 niveles
Patrón
Cada Context sigue esta estructura:
- Tipo del contexto — interfaz con estado + acciones
- Provider — inicializa el estado y expone el value con
useMemo - Hook — consume el Context (vive en
hooks/)
Ejemplo — StepperContext
// features/onboarding/ui/context/stepperContext.tsx
// 1. Tipo: estado + acciones
interface StepperContextValue {
currentStep: number;
totalSteps: number;
isFirstStep: boolean;
isLastStep: boolean;
next: () => void;
back: () => void;
goTo: (step: number) => void;
}
export const StepperContext = React.createContext<StepperContextValue | null>(null);
// 2. Provider
export function StepperProvider({
totalSteps,
children,
}: {
totalSteps: number;
children: React.ReactNode;
}) {
const [currentStep, setCurrentStep] = React.useState(0);
const value = React.useMemo<StepperContextValue>(
() => ({
currentStep,
totalSteps,
isFirstStep: currentStep === 0,
isLastStep: currentStep === totalSteps - 1,
next: () => setCurrentStep((s) => Math.min(s + 1, totalSteps - 1)),
back: () => setCurrentStep((s) => Math.max(s - 1, 0)),
goTo: (step) => setCurrentStep(step),
}),
[currentStep, totalSteps]
);
return (
<StepperContext.Provider value={value}>{children}</StepperContext.Provider>
);
}
// features/onboarding/ui/hooks/useStepper.ts
// 3. Hook
export function useStepper() {
const ctx = React.useContext(StepperContext);
if (!ctx) throw new Error('useStepper must be used within a StepperProvider');
return ctx;
}
Cómo se consume
// En cualquier widget dentro del StepperProvider
const { currentStep, isLastStep, next, back } = useStepper();
Si el Context es compartido entre múltiples features, vive en shared/. Si es local a un solo feature, vive en features/<feature>/ui/context/.
🧪 Testing de esta capa
Para ver lineamientos, alcance y ejemplos de pruebas del UI layer, consulta: