React Modal Stack Architecture for Scalable Apps
In complex React apps, modals quickly become a headache. A single modal with basic useState works fine in isolation, but as the project grows, you end up with duplicated components, prop drilling, and tangled state dependencies. The fix? A typed overlay stack that separates logic by trigger source.
Take a typical marketplace admin dashboard with a products table. Each row needs a modal for adding identifiers (IMEI, SGTIN), complete with order tables, validation, Excel uploads, and a multi-step wizard.
Problems with Local Rendering
Rendering an IdentificationModal in every table row creates thousands of hidden DOM elements. The component is packed with tables, masked inputs, RTK Query hooks, and a stepper—memory usage skyrockets.
Here's a bad example:
const ProductRow = ({ product }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<tr>
<td>{product.name}</td>
<td>
<Button onClick={() => setIsOpen(true)}>Add Identifiers</Button>
<IdentificationModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
productId={product.id}
/>
</td>
</tr>
);
};
Key takeaway: Modals should render once at the page level.
Prop Drilling and Global State
Moving the modal up the tree fixes the DOM bloat but means passing callbacks through 4–5 component layers:
ProductsPage→ProductsTable→TableBody→ProductRow→ActionsMenu
Redux with openIdent(productId) dispatches eliminates props, but gets messy with multiple contexts.
Multiple Data Sources
The modal pulls from three spots with different APIs:
| Trigger Source | Orders | Data Loading | Excel Template |
|---------------|--------|--------------|----------------|
| Table Header | All orders | ordersApi.getIdentOrders() | ordersApi.getExcel() |
| Table Row | Single order | Filter by orderId | ordersApi.getExcel() |
| Cart | Cart orders | cartsApi.getCartIdentOrders(cartId) | cartsApi.getExcel() |
Cramming logic into the modal turns it into a God Object full of if statements, cache invalidation, and analytics. The alternative—a pure UI modal with orders and onSave props—fails because the page doesn't know the context upfront.
Modal Dependencies
Opening the identifier modal hides the bulk actions panel; closing it brings it back. Adding a confirmation modal ("Unsaved changes?") means manually juggling flags:
openConfirmClose: (state) => {
state.isConfirmOpen = true;
state.isIdentOpen = false;
},
cancelConfirmClose: (state) => {
state.isConfirmOpen = false;
state.isIdentOpen = true;
},
Flat flag structures don't capture stacking order or rollback strategies.
Solution Architecture: Typed Stack
This system tackles three issues at once:
- Single rendering via a Redux stack with an overlay queue.
- Context separation through source wrappers.
- Automatic dependency management with stack rules.
Redux Slice for the Stack
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[];
The stack holds typed data and order. Async actions push overlays with payloads.
Overlay Hooks for Triggers
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 };
};
Row button: open('identFromRow', { productId, orderId }). The modal reads from the stack—no prop drilling.
Source Wrappers for Contexts
Each source exports an adapter:
// 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 };
};
The modal gets prepped orders and onSave without knowing the source.
Open/Close Strategies
The stack enforces rules:
- Replacement: Opening
identclosesbulkActions. - Pause: Closing resumes the previous overlay.
- Clear: Confirmation resets the stack.
const openOverlay = createAsyncThunk(
'overlay/open',
async ({ name, data }, { getState }) => {
const stack = selectStack(getState());
// Replacement/priority logic
return { name, data };
}
);
Key Benefits
- The stack eliminates prop drilling, DOM duplication, and state dependencies with one architecture.
- Source wrappers isolate API logic; modals stay pure UI.
OverlayItem<TData>typing ensures data safety.- Async strategies automate open/close order.
- Scales to dozens of modals without bloating the reducer.
— Editorial Team
No comments yet.