On the previous article, we talked about client-side fetching and caching with React Query. In this one, I want to talk about something related but different: how we handled global UI actions without bringing in a global state management library.
When people hear “state management,” the first things that usually come to mind are Redux, Zustand, or some big Context tree wrapping the whole app.
We had those conversations too.
But after looking at what we actually needed, we realized something important: a lot of our “global state” wasn’t really shared application state. It was more like:
“From this button over here, do that thing over there.”
So instead of putting everything into a store, we built a small event-based pattern. In this article, I’ll walk through the first piece of that pattern: our global Modal component.
What we needed
In our app, modals show up in many different places:
- Logout confirmation on the profile page
- Delete Credit card confirmation in bank cards
- Success messages after adding or deleting a card
- Full-screen gallery content on the vendor page
The important part was this: the component that triggered the modal was often far away from the component that rendered it.
For example, a logout button lives deep inside the profile page. But we don’t want every page to mount its own modal overlay, manage open/close animation, and handle backdrop clicks again and again.
We wanted one modal host, mounted once, that any part of the app could talk to.
Something like:
Any component
│
│ "triggers an event for displaying some content"
▼
Global Modal (mounted in layout)
│
▼
Shows overlay + content
What we didn’t want
We didn’t want Redux or Zustand just for opening a modal.
Not because those tools are bad. They’re great when you have real shared business state: cart data, auth session shape, complex multi-step flows with lots of derived state, and so on.
But for our case, a global store would have meant:
Creating a store only to hold isOpen and content
Wiring actions like openModal / closeModal
Importing that store everywhere
Paying the mental cost of “another global state layer”
For a modal, that felt too heavy.
We also didn’t want to pass modal callbacks through 5 levels of props, or wrap half the tree in a Modal Context just so a random leaf component could open a dialog.
So the question became:
Can we raise an event from anywhere, and let one mounted Modal listen and react?
Yes. And the browser already gives us a good tool for that: CustomEvent.
You can read more about it in this article.
The idea in one sentence
We mount one in the root layout.
Any component can call triggerModalEvent(...).
The Modal listens, receives the content, and opens.
No Redux. No Zustand. No prop drilling.
Step 1: Mount one Modal in the root layout
The first decision was architectural: the modal should live at the app root.
In our root layout, next to other global UI pieces, we render:
Why here?
Because the modal needs to:
- Sit above page content
- Be available on every route
- Survive navigation between pages
- Own the overlay and animation once, not per page
- This component is the listener. Everything else is just a publisher.
Step 2: Create a tiny modal emitter
The heart of this pattern is a very small utility: modalEmitter.
It has two jobs:
- triggerModalEvent — fire an event with modal data
- subscribeToModal — listen for that event Here’s the idea:
That’s it.
No store. No selectors. No reducers.
We put the modal payload inside detail, and we use window as the bus
.
Why window?
Because any client component in the app can dispatch an event to it, and our single Modal instance can subscribe once when it mounts.
Step 3: Make the Modal listen and render
The Modal component keeps only the state it needs locally:
- whether it is mounted
- whether it is visually open
- which content it should show
On mount, it subscribes:
A few details here mattered to us:
isOpen and modalData live inside the Modal itself.
They are not global app state. They are UI state owned by the component that renders the UI.
Animation needs two steps
We first mount the modal (isMounted = true), then open it on the next frame (isOpen = true).
That way CSS transitions can run from closed → open instead of appearing instantly.
Closing also has two steps
When the user closes the modal, we set isOpen to false first, wait for the transition to finish, then unmount and clear the content:
and then pass each step to the related tag:

This keeps the exit animation clean.
Step 4: Let modal content close itself with Context
Opening the modal from outside is only half of the story.
The content inside the modal also needs a way to close it.
For that, we didn’t use another event. We used a small React Context, scoped only to the Modal tree:
So the responsibilities are split cleanly:
- Opening → event bus (triggerModalEvent)
- Closing from inside → local context (useModalClose) That felt right. Opening needs to work from far away. Closing usually happens inside the modal content itself.
Step 5: Trigger the modal from anywhere
Once this was in place, opening a modal became almost boring — in a good way :).
Example: logout confirmation
From the profile logout button:
Inside LogoutModal, we just call useModalClose() when the user confirms or cancels.
The logout button doesn’t need to know how overlays work.
The Modal doesn’t need to know what logout means.
Each piece stays in its own world.
Where This Approach Fits—and Where It Doesn’t
I want to be clear about the limits of this approach.
This is not a replacement for all state management.
We still use:
- React Query for server/cache state
- Local component state for UI that belongs to one screen
- Small contexts / providers where a real shared React tree needs shared values
The rule of thumb that helped us was:
- If something is shared business data, think about cache/store/context.
- If something is “please do this UI action over there,” an event is often enough.
Global bottom sheets — same idea, different needs
Once the Modal pattern worked, we faced a similar problem with bottom sheets.
Login, add credit card, support, violation report… these also needed to open from many places in the app. Again, we didn’t want Redux. Again, we didn’t want prop drilling.
So we reused the same mindset:
Raise an event from anywhere. Let a sheet listen and open.
But bottom sheets were not exactly the same problem as modals. And that’s why the implementation looks similar at first glance, then quickly becomes different.
What made bottom sheets different from modals?
With modals, the content was usually temporary and flexible.
Logout confirmation, success message, gallery content — each call site could pass whatever React content it wanted into one shared Modal shell.
Bottom sheets were different.
Most of them were named product flows, not free-form content:
- Login
- Add bank card
- Support
- Violation report These sheets had their own forms, steps, API calls, analytics, and business rules. Passing as generic content into one shell would have worked, but it wasn’t the best fit for how we used them.
We also needed things modals didn’t need as much:
- Open more than one sheet in sequence
- Pass small typed data (payload) into a sheet (like UTM source for add card)
- Close everything on navigation
- store actions behind login, then continue what the user wanted to do
So we kept the event idea, but shaped the API around named sheets and a queue.
The idea in one sentence
We define named bottom sheet events.
Any component can call openStackedBottomSheets('login-bottomsheet').
The matching sheet listens, opens, and when it closes, the next queued sheet can open.
Still no Redux. Still no Zustand.
Step 1: Mount the sheets that must be available everywhere
In the root layout, next to <Modal />, we render:
GlobalBottomSheets is the host for the sheets that really need to be global:
Why?
Because flows like login and add-card could be triggered from many unrelated places in our app: homepage banners, wallet, vendor pages, profile, auth-gated actions, and more.
We also load them lazily:
That mattered for performance.Since these sheets are never visible on first paint, there was no reason to put that weight into the shared/homepage chunk.
Not every sheet lives in the root layout
This is an important difference from Modal.
The Modal is one shell, so mounting it once at the root makes sense.
Bottom sheets are separate components. Some of them are truly app-wide.
Mount a sheet where it needs to be available. Trigger it from anywhere with an event. That gave us global behavior without forcing every sheet into the root bundle.
Step 2: Build a bottom sheet emitter with a queue
The Modal emitter was very simple: fire one event, show one modal.
The bottom sheet emitter had to do more.
- Named events instead of free content
- Instead of sending content: , we send an event name:
Each sheet listens to its own name.
Optional detail for typed data (payload)
Some sheets need a little data when they open. For example, add-card needs UTM info:
That payload travels with the event and the sheet reads it on open.
A queue for stacking
This was the biggest difference from Modal.
Sometimes we want more than one sheet in a flow.
So instead of immediately firing every event, we push them into a queue:
Only one sheet is processed at a time. When that sheet closes, it calls:
notifyBottomSheetClosed()
Then the emitter opens the next one in the queue.
That is why the function is called openStackedBottomSheets.
Step 3: Each sheet listens with a shared hook
Every global sheet follows the same subscription pattern through useBottomSheetSubscription:
The hook does two things:
- Listen for that sheet’s open event
- Listen for a global “dismiss all” event
Inside the sheet, open/close state stays local:
So again,
- Opening comes from the event bus
- UI state stays inside the sheet
- Closing notifies the queue so the next sheet can continue
Auth-gated actions with doActionWithAuth
This is one of my favorite uses of the pattern.
Sometimes the user wants to do something that requires login — for example, open a violation report.
We don’t want every button in these scenarios repeats these actions:
- check auth
- open login if needed
- continue the real action after login
- So we built
doActionWithAuth:
What it does:
- If the user is already logged in → run
afterAction - If not → open
login-bottomsheet - After login succeeds → run
afterActionThat’s the event system doing real product work, not just opening UI.
Bottom sheets and Modal working together
These two systems, they often work in the same flow.
For example, after adding a card successfully inside AddCardBottomSheet, we close the sheet and then open a success Modal:
Wrapping up
In this article, we talked about how we handled global UI actions without Redux or Zustand.
For modals, we used one shared shell and passed content through a tiny event bus.
For bottom sheets, we used named sheets, a queue, optional event detail, navigation cleanup, and auth-gated flows on top of the same event idea.
The biggest lesson for me was this: not every cross-component communication problem is a state management problem. Sometimes it’s just an event.
This is the last article in the series about what we built in the new Takhfifan. I hope these articles were useful, and maybe they gave you a different perspective on building modern web applications.
















Top comments (0)