Arquitectura de pila de modales en React para apps escalables
En aplicaciones React complejas, los modales se convierten rápidamente en un dolor de cabeza. Un modal simple con useState funciona bien de forma aislada, pero a medida que el proyecto crece, terminas con componentes duplicados, perforación de props y dependencias de estado enredadas. ¿La solución? Una pila de superposiciones tipada que separa la lógica por fuente de activación.
Imagina un panel de administración típico de un marketplace con una tabla de productos. Cada fila necesita un modal para agregar identificadores (IMEI, SGTIN), completo con tablas de pedidos, validación, cargas de Excel y un asistente multifase.
Problemas con el renderizado local
Renderizar un IdentificationModal en cada fila de la tabla crea miles de elementos DOM ocultos. El componente está repleto de tablas, inputs enmascarados, hooks de RTK Query y un stepper: el uso de memoria se dispara.
Aquí va un ejemplo malo:
const ProductRow = ({ product }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<tr>
<td>{product.name}</td>
<td>
<Button onClick={() => setIsOpen(true)}>Agregar identificadores</Button>
<IdentificationModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
productId={product.id}
/>
</td>
</tr>
);
};
Lección clave: Los modales deben renderizarse una sola vez a nivel de página.
Perforación de props y estado global
Mover el modal hacia arriba en el árbol soluciona el hinchazón del DOM, pero implica pasar callbacks a través de 4-5 capas de componentes:
ProductsPage→ProductsTable→TableBody→ProductRow→ActionsMenu
Usar Redux con dispatch de openIdent(productId) elimina los props, pero se complica con múltiples contextos.
Múltiples fuentes de datos
El modal obtiene datos de tres fuentes con APIs distintas:
| Fuente de activación | Pedidos | Carga de datos | Plantilla Excel |
|---------------|--------|--------------|----------------|
| Encabezado de tabla | Todos los pedidos | ordersApi.getIdentOrders() | ordersApi.getExcel() |
| Fila de tabla | Pedido único | Filtrar por orderId | ordersApi.getExcel() |
| Carrito | Pedidos del carrito | cartsApi.getCartIdentOrders(cartId) | cartsApi.getExcel() |
Amontonar la lógica en el modal lo convierte en un Objeto Dios lleno de sentencias if, invalidación de caché y analíticas. La alternativa —un modal UI puro con props orders y onSave— falla porque la página no conoce el contexto de antemano.
Dependencias de modales
Abrir el modal de identificadores oculta el panel de acciones masivas; cerrarlo lo restaura. Agregar un modal de confirmación ("¿Cambios sin guardar?") implica manejar manualmente banderas:
openConfirmClose: (state) => {
state.isConfirmOpen = true;
state.isIdentOpen = false;
},
cancelConfirmClose: (state) => {
state.isConfirmOpen = false;
state.isIdentOpen = true;
},
Estructuras de banderas planas no capturan el orden de apilamiento ni estrategias de rollback.
Arquitectura de solución: Pila tipada
Este sistema resuelve tres problemas de una vez:
- Renderizado único mediante una pila Redux con cola de superposiciones.
- Separación de contextos a través de wrappers de fuente.
- Gestión automática de dependencias con reglas de pila.
Slice de Redux para la pila
export enum EOverlayName {
BulkActions = 'bulkActions',
IdentFromHeader = 'identFromHeader',
IdentFromRow = 'identFromRow',
IdentFromCart = 'identFromCart',
EditProduct = 'editProduct',
// ...
}
type OverlayItem<TData = unknown> = {
name: EOverlayName;
data: TData;
priority?: number;
};
type OverlayStack = OverlayItem[];
La pila almacena datos tipados y orden. Acciones asíncronas empujan superposiciones con payloads.
Hooks de superposiciones para activadores
const useOverlay = () => {
const dispatch = useDispatch();
const open = <TData>(name: EOverlayName, data: TData) => {
dispatch(openOverlay({ name, data }));
};
const close = (name: EOverlayName) => {
dispatch(closeOverlay(name));
};
return { open, close };
};
Botón de fila: open('identFromRow', { productId, orderId }). El modal lee de la pila —sin perforación de props.
Wrappers de fuente para contextos
Cada fuente exporta un adaptador:
// identFromRow.adapter.ts
export const useIdentFromRowAdapter = () => {
const { open } = useOverlay();
const openIdent = (productId: string, orderId: string) => {
const orders = ordersApi.useGetIdentOrdersQuery();
const filtered = orders.data?.filter(o => o.id === orderId);
open('identFromRow', { orders: filtered, productId });
};
return { openIdent };
};
El modal recibe orders y onSave preparados sin conocer la fuente.
Estrategias de apertura/cierre
La pila impone reglas:
- Reemplazo: Abrir
identcierrabulkActions. - Pausa: Cerrar reanuda la superposición anterior.
- Limpieza: Confirmación resetea la pila.
const openOverlay = createAsyncThunk(
'overlay/open',
async ({ name, data }, { getState }) => {
const stack = selectStack(getState());
// Lógica de reemplazo/prioridad
return { name, data };
}
);
Beneficios clave
- La pila elimina perforación de props, duplicación de DOM y dependencias de estado con una sola arquitectura.
- Los wrappers de fuente aíslan la lógica de API; los modales permanecen como UI pura.
- Tipado
OverlayItem<TData>garantiza seguridad de datos. - Estrategias asíncronas automatizan el orden de apertura/cierre.
- Escala a docenas de modales sin inflar el reducer.
— Editorial Team
Aún no hay comentarios.