Modals are one of the most common UI elements in frontend development.
At my company we didn't have a dedicated design system, so we used the UI library Ant Design (antd).
With antd, the typical pattern is to render each modal inside the component that uses it and manage its visible state in that component's state.
But while building features, you often end up using the same modal from several different places. Writing the same modal code in multiple places and managing several modal states didn't feel right, so I had been thinking about a better modal architecture for a while.
I needed the following three things:
- Manage modals in one place rather than importing them everywhere
- When a modal fires an event, let any component that needs it receive that event
- Pass props into the modal
I finally got around to building it today, and since I'll probably need it again, I decided to write it up on the blog. It's not limited to antd modals either — this pattern works for any kind of UI that you want to manage globally.
This isn't the only valid approach, of course, so if you have a better one, I'd love to hear it.
The stack used is TypeScript, React, React-Redux, and antd.
App.tsx — root component
modal.ts — reducer that manages modal open/close and props
ModalEvents.ts — helpers for creating custom event listeners
ModalManager.tsx — component that manages modals
TestModal.tsx — a test modal
Test.tsx — screen that opens the TestModal
App.tsx
import Test from '@/src/pages/Test';
import ModalManager from '@/src/components/ModalManager';
function App() {
return (
<div className="App">
<Test />
<ModalManager />
</div>
);
}
export default App;This is the app's root component, the one passed to root.render in index.tsx.
If you're using Redux, your index.tsx will already wrap this with Provider.
modal.ts
import update from 'immutability-helper';
const OPENMODAL = 'modal/OPENMODAL' as const;
const CLOSEMODAL = 'modal/CLOSEMODAL' as const;
interface ModalData {
name: string;
props?: { [key in string]: any };
}
export const openModal = (modal: ModalData) => ({
type: OPENMODAL,
payload: modal,
});
export const closeModal = (modal: ModalData) => ({
type: CLOSEMODAL,
payload: modal,
});
type ModalAction = ReturnType<typeof openModal | typeof closeModal>;
type ModalState = {
openedModals: ModalData[];
};
const initialState: ModalState = {
openedModals: [],
};
function modals(state: ModalState = initialState, action: ModalAction): ModalState {
switch (action.type) {
case OPENMODAL: {
const isModal = state.openedModals.findIndex((v) => v.name === action.payload.name) > -1;
return {
...state,
openedModals: isModal ? [...state.openedModals] : [...state.openedModals, action.payload],
};
}
case CLOSEMODAL: {
const findIndex = state.openedModals.findIndex((v) => v.name === action.payload.name);
return update(state, {
openedModals: {
$splice: [[findIndex, 1]],
},
});
}
default:
return state;
}
}
export default modals;This is the Redux slice that manages modals.
It stores the name and props of currently open modals in openedModals.
ModalEvents.ts
export interface EventProps {
eventType: string;
listener: (event?: any) => void;
}
export function on(eventType: string, listener: (event?: any) => void) {
document.addEventListener(eventType, listener);
}
export function off(eventType: string, listener: (event?: any) => void) {
document.removeEventListener(eventType, listener);
}
export function once(eventType: string, listener: (event?: any) => void) {
on(eventType, handleEventOnce);
function handleEventOnce(event: (event?: any) => void) {
listener(event);
off(eventType, handleEventOnce);
}
}
export function trigger(eventType: string, data?: any) {
const event = new CustomEvent(eventType, { detail: data });
document.dispatchEvent(event);
}onsubscribes to an eventoffunsubscribesoncesubscribes for a single eventtriggerfires an event
ModalManager.tsx
import { lazy, Suspense } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '@/src/reduce';
const MODALS = {
Test: lazy(() => import('@/src/components/Modals/Test')),
};
function ModalManager() {
const { openedModals } = useSelector((state: RootState) => state.modals);
return (
<>
{openedModals.map(({ name, props }) => {
return (
<Suspense key={name}>
{(() => {
switch (name) {
case 'Test': {
return <MODALS.Test {...props} />;
}
default: {
return <></>;
}
}
})()}
</Suspense>
);
})}
</>
);
}
export default ModalManager;It maps over openedModals, looks up each modal name stored in Redux, and renders the matching modal.
TestModal.tsx
import { Modal, ModalProps } from 'antd';
import { trigger } from '@/src/util/ModalEvents';
interface TestModalProps extends ModalProps {
data?: any;
}
function TestModal(props: TestModalProps) {
const handleEvent = () => {
trigger('SUCCESS');
// You can also pass data as the second argument like this.
// trigger('SUCCESS', data);
};
return (
<Modal {...props} visible={true}>
<button onClick={handleEvent}>{data}</button>
</Modal>
);
}
export default TestModal;This is the modal rendered on screen.
When the button inside the modal is clicked, handleEvent runs and calls trigger.
Test.tsx
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { openModal } from '@/src/reduce/modals';
import { on, off } from '@/src/util/ModalEvents';
function Test() {
const dispatch = useDispatch();
const openTestModal = () => {
dispatch(openModal({ name: 'Test' }));
// You can pass props like this.
// dispatch(openModal({ name: 'Test', props: { data } }));
};
const handleModalEvent = (e: CustomEventInit) => {
console.log('success');
// You can receive the data sent by `trigger` from `e.detail`.
};
useEffect(() => {
on('SUCCESS', handleModalEvent);
return () => {
off('SUCCESS', handleModalEvent);
};
}, []);
return (
<div>
<button onClick={openTestModal}>open Modal</button>
</div>
);
}
export default Test;This is the screen that opens TestModal.
The trigger fired inside TestModal is received in useEffect through on.
Update on 22.04.27
const [data, setData] = useState(null);
setData({ a: 1 });
const callback = () => {
console.log(data); // null
};
// Before
useEffect(() => {
on('SUCCESS', callback);
return () => {
off('SUCCESS', callback);
};
}, []);
// After
useEffect(() => {
on('SUCCESS', callback);
return () => {
off('SUCCESS', callback);
};
}, [callback]);I noticed this while using the pattern at work.
The callback that runs when on receives an event is the one captured at the time the listener was registered (at mount time). So if the callback reads state from the outer scope, it sees a stale value instead of the latest one.
If you add the callback to the useEffect dependency array, the event is re-registered every time the callback changes, so the callback can read the latest values.
If re-registering on every render feels wasteful, wrapping the callback with useCallback should solve it too.
In summary: ModalManager manages modals in one place, and when a modal fires an event with trigger, any interested component can receive it via on.