DEV Community

Viktor Shcheglov
Viktor Shcheglov

Posted on • Updated on

Mastering Modal Dialogs in React Like a Pro

Image description

Modals, dialogs, popups, snackbars, notification, alert, confirm and etc…

It's hard to imagine 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 raise 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.

Problem

Let's take a look at this typical example of how developers usually display modals:

№1: Boilerplate
Managing dialog in 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);
Enter fullscreen mode Exit fullscreen mode

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.

№2: Performance

Image description

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.

№3: Package size

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.

Solution

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.

Image description

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.

The Solution — Modal Manager

💡 My idea is to create a single entry point for all dialogs, allowing us to manage them as conveniently as we do 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')
  ),
}
Enter fullscreen mode Exit fullscreen mode

The DialogManager - just a simple hashmap, where each dialog is associated with a unique key and contain a lazy component of the dialog.

Provider

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
>(undefined)
Enter fullscreen mode Exit fullscreen mode

Let's break down our magic in more detail:

Let's delve deeper into our methodology:

  1. In the activeDialogs state, we accumulate our dialogs. This is an array because we anticipate that multiple dialogs might be called one after another.
  2. 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.
  3. The open function is generic, thanks to , it knows the props our dialog accepts. This will be incredibly useful for invoking our dialogs in the future.
  4. close - simply deletes our component from activeDialogs
  5. 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.

Context and part

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

Hook useDialog

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Final

In essence, this is what we've achieved:

  1. A Clearly Defined Dialog Structure: All popular or frequently used popups are extracted and described in a special manager. If you prefer not to use this approach for some dialogs, you can still do it the old way, and everything will work just fine.
  2. Asynchronous Code: We load our code as needed using lazy loading and Suspense, making our application lighter and faster.
  3. No More Boilerplate: Managing the state of our dialogs is now encapsulated within the 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.

Top comments (0)