Skip to Content
UNIMAST
DocumentaciónDesarrollarEstado y Contextos

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.