It's hard to imagine a modern React Application without pop-up dialogs for confirming addresses, deleting items from a cart, and other such interactions — challenges every developer faces daily.
The design and management architecture of these dialogs often raises numerous questions. I've encountered everything from complex integration schemes with stores to extensive prop drilling for opening windows, and intricate setups using event emitters. It's time to tackle these issues head-on.
In this article, I'll share the solution our team has developed, which significantly aids us in maintaining clean code daily, even as our project scales.
Let's take a look at this typical example of how developers usually display models:
Managing dialog in an app often involves creating several state variables to control their opening and closing.
A typical setup for a component might start with several lines of code like this:
const [openConfirm, setOpenConfirm] = useState(false);
const [deleteDialogOpened, setDeleteDialog] = useState(false);
const [editDialogOpened, setEditDialog] = useState(false);
const [moveOnDialogOpened, setMoveOnDialog] = useState(false);
Does this code look familiar?
Can you imagine the complexity involved in rendering this component, where hundreds of lines of code in JSX track how and when these dialogs open? Managing several dialog windows in this way not only clutters the components but also increases the risk of bugs, as the state of each dialog is intertwined with the overall logic of the component.
Imagine you have a table where every cell has its own set of dialog boxes for management tasks. If you put the dialog control logic right inside each cell's component, React ends up doing a lot of work. For every update, it has to check through hundreds of these virtual parts to figure out which dialog boxes to show or hide. This can slow things down because React has to keep comparing all these parts every time something changes. It makes your code more complex and harder to deal with, too.
Moving dialogs to a parent level seems straightforward, but the main issue was this:prop drilling. Ultimately, you end up having to pass data through many layers to reach the dialog windows, which complicates matters. It's like unraveling a knot that only gets tighter the more you pull on it.
In large projects with numerous dialogs, a problem arises: many dialogs are loaded but never used by some users. Without code splitting, other forms of preloading/caching, or similar strategies, the application can become sluggish, loading unnecessary items.
We often see two main types of dialogs in apps. First, there are the common ones, used about 90% of the time, like confirmation messages, map location pickers, ads, or subscription offers.
These dialogs pop up frequently, look similar, and aren't tied to any specific part of the app. Then, there's the other 10%: specialized dialogs made for one-time events or specific actions. By figuring out which dialogs are used most and which are for special occasions, we can make managing and loading them much easier.
My idea is to create a single entry point for all dialogs, allowing us to manage them as conveniently as routes in React applications. This means we will describe their behavior strategy, share data, and invoke them using hooks and React context. By leveraging Suspense, we can ensure that these dialogs are not loaded unnecessarily. This approach streamlines dialog management, making it more efficient and less prone to performance issues. By centralizing dialog control, we can easily adjust their behavior and appearance across the entire application, improving consistency and user experience. Additionally, using Suspense for lazy loading helps keep our app lightweight, loading resources only when they are truly needed.
Let’s do it:
import { lazy } from 'react'
export enum Modals {
ConfirmModal,
SubscriptionModal,
}
export const DialogManager = {
[Modals.ConfirmModal]: lazy(() => import('./modals/ConfirmModal')),
[Modals.SubscriptionModal]: lazy(
() => import('@components/common/Subscriptions')
),
}
The DialogManager
- just a simple hashmap, where each dialog is associated with a unique key and contains a lazy component of the dialog.
As I mentioned before context and provider - it’s an approach to initialize our dialogs and share value in any place, let’s make it:
type DialogStructure = {
id: string
component: () => JSX.Element
}
export type DialogCloseHandler<T = boolean> = {
onCloseHandler?: (value?: T) => T
}
const useDialogProvider = () => {
const [activeDialogs, setActiveDialogs] = useState<Array<DialogStructure>>([])
const close = useCallback(
(id: string, value?: unknown) => {
setActiveDialogs(activeDialogs.filter(dialog => dialog.id !== id))
return value
},
[activeDialogs]
const open = useCallback(
<T extends Modals>(
type: T,
props: Omit<ComponentProps<(typeof dialogManager)[T]>, 'onCloseHandler'>
) => {
const id = nanoid()
const Component = dialogManager[type as Modals]
setActiveDialogs([
{
id,
component: () => (
<Component {...props} onCloseHandler={value => close(id, value)} />
),
},
...activeDialogs,
])
return null
},
[activeDialogs, close]
)
return useMemo(
() => ({ open, close, activeDialogs }),
[open, close, activeDialogs]
)
}
export const DialogContext = createContext<
ReturnType<typeof useDialogProvider> | undefined
Let's delve deeper into our methodology:
activeDialogs
state, we accumulate our dialogs. This is an array because we anticipate that multiple dialogs might be called one after another.useDialogProvider
is what's passed as a value to our DialogContext
. It's crucial that it returns both an opening function and a closing function, as well as a list of all dialogs.open
function is generic, thanks to <ComponentProps<(typeof dialogManager)[T]>
, it knows the props our dialog accepts. This will be incredibly useful for invoking our dialogs in the future.close
- simply deletes our component from activeDialogs
onCloseHandler
is one of the key tricks of our architecture. The only thing our manager-created dialog needs to know is how to close itself. There are numerous ways to teach it this, but the simplest method seems to be incorporating a callback into each of the dialogs invoked by our system that can perform this action.Now, let's focus on our context setup:
export const DialogProvider: FC<PropsWithChildren> = ({ children }) => {
const value = useDialogProvider()
return (
<DialogContext.Provider value={value}>
{children}
{typeof window !== 'undefined' && value.activeDialogs.length ? (
<Suspense fallback={null}>
{value.activeDialogs.map(({ id, component: LazyDialog }) => (
<LazyDialog key={id} />
))}
</Suspense>
) : null}
</DialogContext.Provider>
)
}
Remember how we opted for lazy
wrapping for our dialogs within the manager to facilitate on-demand loading? By incorporating Suspense
in our provider, we streamline this process even further. Now, iterating through any open dialogs in our provider becomes straightforward, ensuring that dialogs are loaded efficiently and only when necessary, enhancing the overall performance and user experience of our application.
This is the final piece of our entire splendid structure. Now, we need to learn how to conveniently invoke our dialogs from anywhere within our application. I suggest doing this with the useDialog
hook, as all the necessary functionality for it is already in place:
export const useDialog = () => {
const context = useContext(DialogContext)
if (context === undefined) {
throw new Error('useDialog() called outside of DialogProvider')
}
return context
}
That's it. Now, in any component, we can simply call:
const {open} = useDialog()
return (
<button onClick={() => open(Modals.subscription, { onConfirm: () => location.reload() })}>Buy subscription</button>
After we use Modals.Subscription
, our TS and IDE will help us correctly identify the missing props. Therefore, we've added an onConfirm
callback because that's what our subscription dialog component requires.
In essence, this is what we've achieved:
useDialog
hook and can be expanded according to the needs of your application.With these improvements, our application benefits from a more organized, efficient, and scalable dialog management system. This structure not only enhances performance but also simplifies development and maintenance, providing a clear path forward for incorporating dialog windows in a React application.