Estado y Contextos
UNIMAST ERP implementa un sistema robusto de gestión de estado basado en React Context API y hooks nativos de React, diseñado para manejar tanto el estado global de la aplicación como el estado local de componentes específicos.
🏗️ Arquitectura del Sistema de Estado
Jerarquía de Providers
RootLayout (app/layout.tsx)
├── Toaster (notificaciones globales)
└── DashboardLayout (app/(dashboard)/layout.tsx)
├── SessionProvider (contexto de sesión)
├── Providers (TooltipProvider)
└── SidebarProvider (estado del sidebar)Tipos de Estado
- Estado Global: Sesión de usuario, configuración del sistema
- Estado de UI: Sidebar, modales, filtros, navegación
- Estado de Formularios: react-hook-form para formularios complejos
- Estado Local: Componentes específicos con useState/useReducer
🔐 Contexto de Sesión (SessionProvider)
Implementación del Contexto
// app/contexts/session-provider.tsx
"use client";
import { createContext, ReactNode, useContext } from "react";
import { Session } from "@/lib/auth/get-session";
export interface SessionContextProps {
session: Session | null;
}
const SessionContext = createContext<SessionContextProps>({
session: null,
});
export const SessionProvider = ({
children,
session,
}: {
children: ReactNode;
session: Session | null;
}) => {
return (
<SessionContext.Provider value={{ session }}>
{children}
</SessionContext.Provider>
);
};
export const useSession = () => useContext(SessionContext);Estructura de la Sesión
// app/lib/auth/get-session.ts
export type Session = {
accessToken: string;
user: User;
role: Role;
};
// Ejemplo de uso del hook
const { session } = useSession();
if (session) {
console.log('Usuario:', session.user.name);
console.log('Rol:', session.role.name);
console.log('Token:', session.accessToken);
}Gestión del Ciclo de Vida
// app/lib/auth/get-session.ts
export async function getSession(): Promise<Session | null> {
const accessToken = (await cookies()).get(COOKIES.AUTH_TOKEN)?.value;
if (!accessToken) {
return null;
}
const session = await prisma.session.findFirst({
where: { sessionToken: accessToken }
});
if (!session || session.expires < new Date()) {
// Sesión expirada o inválida
await prisma.session.delete({
where: { sessionToken: accessToken }
});
return null;
}
const user = await prisma.user.findFirst({
include: { role: true },
where: { id: session.userId }
});
return user ? { accessToken, user, role: user.role } : null;
}Uso en el Layout Principal
// app/app/(dashboard)/layout.tsx
export default async function DashboardLayout({
children
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) {
redirect('/login');
}
return (
<SessionProvider session={session}>
<Providers>
<TooltipProvider>
<SidebarProvider>
{/* Contenido del dashboard */}
{children}
</SidebarProvider>
</TooltipProvider>
</Providers>
</SessionProvider>
);
}🧭 Contexto del Sidebar (SidebarProvider)
Implementación del Contexto
// app/contexts/sidebar-provider.tsx
'use client';
import { createContext, ReactNode, useContext, useState } from 'react';
export interface SidebarContextProps {
expanded: boolean;
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextProps>({
expanded: true,
toggleSidebar: () => {}
});
export const SidebarProvider = ({ children }: { children: ReactNode }) => {
const [expanded, setExpanded] = useState(true);
const toggleSidebar = () => {
setExpanded((prev) => {
const newState = !prev;
// Persistir en localStorage
localStorage.setItem('sidebarExpanded', String(newState));
// Disparar evento personalizado
window.dispatchEvent(
new CustomEvent('sidebarStateChange', {
detail: { expanded: newState }
})
);
return newState;
});
};
return (
<SidebarContext.Provider value={{ expanded, toggleSidebar }}>
{children}
</SidebarContext.Provider>
);
};
export const useSidebar = () => useContext(SidebarContext);Características del Sidebar
- Estado persistente en localStorage
- Eventos personalizados para sincronización entre componentes
- Toggle automático con animaciones
- Responsive para dispositivos móviles
Uso en Componentes
// app/app/(dashboard)/collapsible-sidebar.tsx
import { useSidebar } from '@/contexts/sidebar-provider';
export function CollapsibleSidebar() {
const { expanded, toggleSidebar } = useSidebar();
return (
<aside className={cn(
"transition-all duration-300 ease-in-out",
expanded ? "w-64" : "w-16"
)}>
<button onClick={toggleSidebar}>
{expanded ? <ChevronLeft /> : <ChevronRight />}
</button>
{/* Contenido del sidebar */}
</aside>
);
}🎯 Providers de UI (TooltipProvider)
Implementación del Provider
// app/app/(dashboard)/providers.tsx
'use client';
import { TooltipProvider } from '@/components/ui/tooltip';
export default function Providers({ children }: { children: React.ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>;
}Integración en el Layout
// app/app/(dashboard)/layout.tsx
import { TooltipProvider } from '@/components/ui/tooltip';
return (
<SessionProvider session={session}>
<Providers>
<TooltipProvider>
<main className="flex min-h-screen w-full flex-col bg-muted/40">
<SidebarProvider>
{/* Contenido */}
</SidebarProvider>
</main>
</TooltipProvider>
</Providers>
</SessionProvider>
);📊 Gestión de Estado Local
useState para Estado Simple
// app/components/confirmation-dialog.tsx
export function ConfirmationDialog({
isOpen,
onConfirm,
onCancel
}: ConfirmationDialogProps) {
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm();
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onCancel}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar Acción</DialogTitle>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button
onClick={handleConfirm}
disabled={loading}
>
{loading ? 'Procesando...' : 'Confirmar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}useState para Estado de Formularios
// app/app/(dashboard)/invoices/create-invoice-dialog.tsx
export function CreateInvoiceDialog() {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<InvoiceFormValues['items']>([
{ description: '', quantity: 1, price: 0, taxRate: 0 }
]);
const addItem = () => {
setItems(prev => [...prev, {
description: '',
quantity: 1,
price: 0,
taxRate: 0
}]);
};
const removeItem = (index: number) => {
setItems(prev => prev.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: keyof InvoiceItem, value: any) => {
setItems(prev => prev.map((item, i) =>
i === index ? { ...item, [field]: value } : item
));
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<form onSubmit={handleSubmit}>
{items.map((item, index) => (
<div key={index} className="flex gap-2">
<Input
value={item.description}
onChange={(e) => updateItem(index, 'description', e.target.value)}
placeholder="Descripción del item"
/>
<Input
type="number"
value={item.quantity}
onChange={(e) => updateItem(index, 'quantity', parseInt(e.target.value))}
min="1"
/>
<Input
type="number"
value={item.price}
onChange={(e) => updateItem(index, 'price', parseFloat(e.target.value))}
min="0"
step="0.01"
/>
<Button
type="button"
variant="destructive"
onClick={() => removeItem(index)}
>
Eliminar
</Button>
</div>
))}
<Button type="button" onClick={addItem}>
Agregar Item
</Button>
</form>
</DialogContent>
</Dialog>
);
}useState para Filtros y Búsquedas
// app/app/(dashboard)/invoices/invoices-filter.tsx
export function InvoicesFilter() {
const [startDate, setStartDate] = useState<Date | undefined>(
searchParams.get('startDate')
? new Date(searchParams.get('startDate')!)
: undefined
);
const [endDate, setEndDate] = useState<Date | undefined>(
searchParams.get('endDate')
? new Date(searchParams.get('endDate')!)
: undefined
);
const [status, setStatus] = useState(
searchParams.get('status') || 'ANY'
);
const applyFilters = () => {
const params = new URLSearchParams();
if (startDate) params.set('startDate', startDate.toISOString());
if (endDate) params.set('endDate', endDate.toISOString());
if (status !== 'ANY') params.set('status', status);
router.replace(`${pathname}?${params.toString()}`);
};
const clearFilters = () => {
setStartDate(undefined);
setEndDate(undefined);
setStatus('ANY');
router.replace(pathname);
};
return (
<div className="flex gap-4 items-center">
<DatePicker
selected={startDate}
onChange={setStartDate}
placeholderText="Fecha inicial"
/>
<DatePicker
selected={endDate}
onChange={setEndDate}
placeholderText="Fecha final"
/>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANY">Cualquier estado</SelectItem>
<SelectItem value="PENDING">Pendiente</SelectItem>
<SelectItem value="PAID">Pagada</SelectItem>
<SelectItem value="CANCELLED">Cancelada</SelectItem>
</SelectContent>
</Select>
<Button onClick={applyFilters}>Aplicar Filtros</Button>
<Button variant="outline" onClick={clearFilters}>
Limpiar
</Button>
</div>
);
}🔄 Estado Avanzado con useMemo y useCallback
useMemo para Cálculos Costosos
// app/components/advanced-filters/index.tsx
export function AdvancedFilters({ columns }: AdvancedFiltersProps) {
const [filterState, setFilterState] = useState<FilterRule[]>([]);
// Memoizar agrupación de columnas para evitar recálculos
const groupedColumns: Record<string, Column> = useMemo(() => {
return columns.reduce((acc, item) => {
acc[item.key] = item;
return acc;
}, {} as any);
}, [columns]);
// Memoizar validación de filtros
const isValidFilter = useMemo(() => {
return filterState.every(rule =>
rule.field && rule.operator && rule.value
);
}, [filterState]);
return (
<div>
{/* Renderizado de filtros */}
<Button
disabled={!isValidFilter}
onClick={applyFilters}
>
Aplicar Filtros
</Button>
</div>
);
}useCallback para Funciones Estables
// app/app/(dashboard)/customer-pay/payment-form.tsx
export function PaymentForm() {
const [selectedInvoice, setSelectedInvoice] = useState<Factura | null>(null);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(null);
// Memoizar función de búsqueda para evitar re-renders
const handleInvoiceSearch = useCallback(async (query: string) => {
if (query.length < 3) return;
try {
const results = await searchInvoices(query);
setSearchResults(results);
} catch (error) {
toast.error('Error al buscar facturas');
}
}, []);
// Memoizar función de cálculo de total
const calculateTotal = useCallback(() => {
if (!selectedInvoice) return 0;
const subtotal = selectedInvoice.items.reduce(
(sum, item) => sum + (item.quantity * item.price),
0
);
const tax = subtotal * (selectedInvoice.taxRate / 100);
return subtotal + tax;
}, [selectedInvoice]);
return (
<form>
<div className="space-y-4">
<InvoiceSearch onSearch={handleInvoiceSearch} />
{selectedInvoice && (
<div>
<h3>Factura Seleccionada</h3>
<p>Total: ${calculateTotal().toFixed(2)}</p>
</div>
)}
</div>
</form>
);
}🎭 Gestión de Estado de Modales y Diálogos
Estado de Apertura/Cierre
// app/app/(dashboard)/settings/users/user-modal.tsx
export function UserModal({ user, roles }: UserModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [isEditing, setIsEditing] = useState(!!user);
const openModal = () => {
setIsOpen(true);
if (user) {
setIsEditing(true);
// Pre-llenar formulario con datos del usuario
form.reset({
name: user.name,
email: user.email,
roleId: user.roleId
});
}
};
const closeModal = () => {
setIsOpen(false);
setIsEditing(false);
form.reset();
};
return (
<>
<Button onClick={openModal}>
{user ? 'Editar Usuario' : 'Nuevo Usuario'}
</Button>
<Dialog open={isOpen} onOpenChange={closeModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditing ? 'Editar Usuario' : 'Crear Usuario'}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* Campos del formulario */}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeModal}>
Cancelar
</Button>
<SubmitButton>
{isEditing ? 'Actualizar' : 'Crear'}
</SubmitButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}Estado de Carga y Errores
// app/app/(dashboard)/ecf/received/send-approval-dialog.tsx
export function SendApprovalDialog({ document }: SendApprovalDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (values: ApprovalFormData) => {
setIsLoading(true);
try {
await sendApproval(document.id, values);
toast.success('Aprobación enviada exitosamente');
setIsOpen(false);
} catch (error) {
toast.error('Error al enviar la aprobación');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<form onSubmit={form.handleSubmit(handleSubmit)}>
{/* Campos del formulario */}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
disabled={isLoading}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enviando...
</>
) : (
'Enviar Aprobación'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}🔄 Sincronización de Estado
Eventos Personalizados para Comunicación
// app/contexts/sidebar-provider.tsx
const toggleSidebar = () => {
setExpanded((prev) => {
const newState = !prev;
// Persistir en localStorage
localStorage.setItem('sidebarExpanded', String(newState));
// Disparar evento personalizado para sincronización
window.dispatchEvent(
new CustomEvent('sidebarStateChange', {
detail: { expanded: newState }
})
);
return newState;
});
};Escucha de Eventos en Componentes
// app/app/(dashboard)/sidebar-content-wrapper.tsx
export function SidebarContentWrapper({ children }: { children: ReactNode }) {
const [sidebarExpanded, setSidebarExpanded] = useState(true);
useEffect(() => {
// Escuchar cambios del sidebar desde otros componentes
const handleSidebarChange = (event: CustomEvent) => {
setSidebarExpanded(event.detail.expanded);
};
window.addEventListener('sidebarStateChange', handleSidebarChange);
// Restaurar estado desde localStorage
const savedState = localStorage.getItem('sidebarExpanded');
if (savedState !== null) {
setSidebarExpanded(savedState === 'true');
}
return () => {
window.removeEventListener('sidebarStateChange', handleSidebarChange);
};
}, []);
return (
<div className={cn(
"transition-all duration-300 ease-in-out",
sidebarExpanded ? "ml-64" : "ml-16"
)}>
{children}
</div>
);
}🚀 Mejores Prácticas
1. Separación de Responsabilidades
// ✅ CORRECTO: Estado global en contextos, estado local en componentes
const UserList = () => {
// Estado local del componente
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
// Estado global desde contexto
const { session } = useSession();
return (
<div>
{/* UI del componente */}
</div>
);
};
// ❌ INCORRECTO: Mezclar estado global y local en el mismo contexto
const GlobalStateContext = createContext({
users: [],
selectedUsers: [], // Esto debería ser estado local
searchQuery: '', // Esto debería ser estado local
session: null // Esto sí es estado global
});2. Memoización Inteligente
// ✅ CORRECTO: Memoizar solo cuando es necesario
const expensiveCalculation = useMemo(() => {
return data.filter(item => item.status === 'active')
.map(item => ({ ...item, computed: item.value * 2 }))
.sort((a, b) => b.computed - a.computed);
}, [data]); // Solo recalcular cuando data cambie
// ✅ CORRECTO: Callbacks estables para props de componentes
const handleUserSelect = useCallback((userId: string) => {
setSelectedUsers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
}, []); // Dependencias vacías = función estable
// ❌ INCORRECTO: Crear nuevas funciones en cada render
const handleUserSelect = (userId: string) => {
setSelectedUsers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
};3. Gestión de Estado de Formularios
// ✅ CORRECTO: Usar react-hook-form para formularios complejos
const UserForm = () => {
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
name: '',
email: '',
roleId: ''
}
});
const onSubmit = form.handleSubmit(async (values) => {
try {
await createUser(values);
toast.success('Usuario creado');
form.reset();
} catch (error) {
toast.error('Error al crear usuario');
}
});
return (
<Form {...form}>
<form onSubmit={onSubmit}>
{/* Campos del formulario */}
</form>
</Form>
);
};
// ✅ CORRECTO: useState para formularios simples
const SimpleForm = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// Lógica simple de envío
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nombre"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button type="submit">Enviar</button>
</form>
);
};4. Persistencia de Estado
// ✅ CORRECTO: Persistir solo estado importante
const SidebarProvider = ({ children }: { children: ReactNode }) => {
const [expanded, setExpanded] = useState(() => {
// Restaurar desde localStorage solo al inicializar
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('sidebarExpanded');
return saved ? saved === 'true' : true;
}
return true;
});
const toggleSidebar = useCallback(() => {
setExpanded(prev => {
const newState = !prev;
localStorage.setItem('sidebarExpanded', String(newState));
return newState;
});
}, []);
return (
<SidebarContext.Provider value={{ expanded, toggleSidebar }}>
{children}
</SidebarContext.Provider>
);
};
// ❌ INCORRECTO: Persistir todo el estado
const BadProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState(() => {
// No persistir estado temporal o sensible
const saved = localStorage.getItem('appState');
return saved ? JSON.parse(saved) : initialState;
});
useEffect(() => {
// No guardar en cada cambio
localStorage.setItem('appState', JSON.stringify(state));
}, [state]);
return (
<Context.Provider value={{ state, setState }}>
{children}
</Context.Provider>
);
};🔧 Troubleshooting
Problemas Comunes
1. Re-renders Excesivos
// ❌ PROBLEMA: Componente se re-renderiza en cada cambio
const BadComponent = () => {
const [count, setCount] = useState(0);
// Esta función se crea en cada render
const handleClick = () => setCount(count + 1);
return <button onClick={handleClick}>{count}</button>;
};
// ✅ SOLUCIÓN: Usar useCallback
const GoodComponent = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return <button onClick={handleClick}>{count}</button>;
};2. Estado No Sincronizado
// ❌ PROBLEMA: Estado desincronizado entre componentes
const ComponentA = () => {
const [value, setValue] = useState('');
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
};
const ComponentB = () => {
const [value, setValue] = useState(''); // Estado separado
return <div>{value}</div>;
};
// ✅ SOLUCIÓN: Usar contexto compartido
const ValueContext = createContext<{
value: string;
setValue: (value: string) => void;
}>({ value: '', setValue: () => {} });
const ValueProvider = ({ children }: { children: ReactNode }) => {
const [value, setValue] = useState('');
return (
<ValueContext.Provider value={{ value, setValue }}>
{children}
</ValueContext.Provider>
);
};
const ComponentA = () => {
const { value, setValue } = useContext(ValueContext);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
};
const ComponentB = () => {
const { value } = useContext(ValueContext);
return <div>{value}</div>;
};3. Memory Leaks
// ❌ PROBLEMA: Event listeners no limpiados
const BadComponent = () => {
useEffect(() => {
const handleResize = () => console.log('Resized');
window.addEventListener('resize', handleResize);
// Falta cleanup
}, []);
return <div>Component</div>;
};
// ✅ SOLUCIÓN: Limpiar event listeners
const GoodComponent = () => {
useEffect(() => {
const handleResize = () => console.log('Resized');
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>Component</div>;
};Debug de Estado
// Agregar logging para debugging
const useDebugState = (name: string, value: any) => {
useEffect(() => {
console.log(`[${name}] State changed:`, value);
}, [name, value]);
};
const MyComponent = () => {
const [count, setCount] = useState(0);
useDebugState('count', count);
return (
<button onClick={() => setCount(prev => prev + 1)}>
Count: {count}
</button>
);
};
// Debug de contextos
const useDebugContext = (contextName: string, context: any) => {
useEffect(() => {
console.log(`[${contextName}] Context value:`, context);
}, [contextName, context]);
};
const UserProfile = () => {
const session = useSession();
useDebugContext('session', session);
return <div>User: {session?.user?.name}</div>;
};¿Necesitas más detalles sobre algún aspecto específico? Revisa la documentación de autenticación para entender cómo se integra el estado de sesión con el sistema de permisos, o la documentación de UI y formularios para ver ejemplos de gestión de estado en formularios complejos.