paint-brush
Mastering Modal Dialogs in React Like a Proby@shcheglov
1,363 reads
1,363 reads

Mastering Modal Dialogs in React Like a Pro

by Viktor ShcheglovFebruary 23rd, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Managing pop-up dialogs in React apps can be complex and cumbersome, leading to performance issues and bloated code. This comprehensive guide offers a structured solution using hooks, context, lazy loading, and Suspense to streamline dialog management, enhance performance, and maintain clean, scalable code. By centralizing dialog control and leveraging asynchronous loading, developers can improve user experience and simplify development and maintenance workflows in React applications.
featured image - Mastering Modal Dialogs in React Like a Pro
Viktor Shcheglov HackerNoon profile picture



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.


Popups hell not only for the user but also for programmers working with them.


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.


Problem

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

№1: Boilerplate

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.


№2: Performance

table 1000+ lines and active buttons


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.


Solutions

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.

Typical dialog

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.

Modal Manager

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.

Provider

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:


  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 <ComponentProps<(typeof dialogManager)[T]>, 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 suspense 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>
)
}


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
}


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.


Finally

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.