I Was Asked to Build a Global Modal System in a Lead Frontend Interview — Here’s How I Designed It
In a recent interview for a Lead Frontend Engineer role, I got a live system design + implementation challenge:
“Design a global modal architecture that can be used from anywhere in the app.”
What looked like a simple UI task quickly became a great discussion on architecture, state boundaries, accessibility, and API design.
I was also asked to write custom CSS for the modal UI, so the exercise covered both behavior and presentation.
This post is a breakdown of my approach, the follow-up questions I got, and the refinements I made afterward.
The Requirements I Wrote Down First
Before coding, I confirmed the contract:
- Open a modal from anywhere in the app.
- Close an open modal.
- Support stacking multiple modals.
- Keep last-opened modal active; when it closes, reveal the previous one.
One thing I intentionally added was custom content support. It was not explicitly stated, but I read between the lines and treated it as an implicit requirement for a reusable modal system.
That gave me a clear target and helped keep implementation decisions focused.
Architecture Choice: Context + Provider + Portal
I used a global ModalProvider and exposed a useModal() hook through React Context.
// src/contexts/ModalContext.tsx
const ModalContext = createContext<ModalContextType | undefined>(undefined)
export function useModal(): ModalContextType {
const context = useContext(ModalContext)
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider')
}
return context
}
At the app root, I mounted the provider and portal once:
// src/routes/__root.tsx
<ModalProvider>
{children}
<ModalPortal />
</ModalProvider>
This gives every route/component access to openModal and closeModal without prop drilling.
Why Context Was the Right Fit (Not Redux)
One of the strongest interview discussions was around state-management tradeoffs.
I explained why Context was a better fit than Redux/external stores for this specific problem:
- Modal state is UI-local to the React tree.
- Updates are event-based and low frequency (open/close), not high-throughput.
- Scope is narrow (modal stack + lifecycle handlers).
- Consumers are known and contained under one provider.
Using Redux here would add ceremony (store setup, actions, reducers, selectors, middleware decisions) without meaningful upside.
My framing was simple: pick the least complex abstraction that fully satisfies current requirements.
Modal API Design: Flexible but Controlled
I kept openModal composable by accepting custom content and optional callbacks:
Even though custom content was not directly requested, I considered it part of solving the real problem (a globally reusable modal, not a one-off dialog).
// src/contexts/ModalContext.tsx
export interface OpenModalOptions {
title: string
content: ReactNode
onClose?: () => void
onConfirm?: () => void | Promise<void>
}
Example usage:
// src/routes/index.tsx
openModal({
title: 'Modal Example',
content: <p>This is a modal.</p>,
})
This supports plain text, rich JSX, and feature-specific components without changing modal internals.
Closing Strategy: Top-Only by Default, ID-Based When Needed
I implemented close behavior with two modes:
-
closeModal()→ closes the top modal -
closeModal(id)→ closes a specific modal
// src/contexts/ModalContext.tsx
const closeModal = (id?: string): void => {
let modalToClose: Modal | undefined
setModals((prevModals) => {
if (prevModals.length === 0) return prevModals
if (!id) {
modalToClose = prevModals[prevModals.length - 1]
return prevModals.slice(0, -1)
}
const updatedModals = prevModals.filter((m) => {
const shouldKeep = m.id !== id
if (!shouldKeep) modalToClose = m
return shouldKeep
})
return updatedModals.length === prevModals.length ? prevModals : updatedModals
})
modalToClose?.onClose?.()
}
I also wired Escape and backdrop-click close only for the active modal.
Stacking Behavior: Array as LIFO Stack
To support multiple modals, I modeled state as modals: Modal[] and append on open.
// src/contexts/ModalContext.tsx
const [modals, setModals] = useState<Modal[]>([])
const openModal = (options: OpenModalOptions): string => {
const id = crypto.randomUUID()
const newModal: Modal = { ...options, id }
setModals((prevModals) => [...prevModals, newModal])
return id
}
In the portal, index === modals.length - 1 determines the active modal and z-order.
Follow-Up Question: “What If Consumers Need Extra Actions?”
Toward the end, I got this question:
“What if users want to dispatch additional actions on close or confirm?”
That’s exactly why I added optional callbacks:
-
onClose: analytics, cleanup, UI sync actions -
onConfirm: sync/async domain actions, then close flow
This keeps modal infrastructure generic while letting product features inject business behavior at call sites.
Accessibility Follow-Up: Focus Trapping
I was also asked how I’d prevent keyboard focus from escaping the modal.
I implemented focus trapping for Tab / Shift+Tab, and restored focus to the previously focused element when the modal unmounts.
This significantly improves keyboard UX and accessibility compliance for stacked dialogs.
What I Refined After the Interview
I had extra time afterward, so I polished the solution further:
- clarified API boundaries
- tightened callback behavior
- improved accessibility details
That post-interview refinement made the implementation feel more production-ready than interview-only.
Final Takeaway
My core principle was to treat modals as app infrastructure, not one-off UI widgets:
- globally accessible API
- predictable stack semantics
- composable content model
- accessibility-first behavior
If you’d review this architecture differently, I’d love to hear what you would change.
Final Implementation
You can explore the full implementation here:
https://codesandbox.io/p/github/shrinivasshah/modal-stack-lld/main
Top comments (0)