How to Handle Modals In A Large-Scale React App — Render Manager

Photo by Oskar Kadaksoo on Unsplash

Photo by Oskar Kadaksoo on Unsplash

Modals, Dialogs, Notifications, and Snackbars, are all examples of “global components” — as I call them. These are components that should be displayed at an app level and should be triggered from anywhere.

I work with large-scale applications which show a lot of Modals and notifications and sometimes, we even need to component them on the fly and not have a specific component for them.

The Problem

Let's look at this typical example of how developers normally render Modals:

function App() {
  const [editModalVisible, setEditModalVisble] = useState(false);

  const displayEditModal = () => {
    setEditModalVisble(true)
  }

  const hideEditModal = () => {
    setEditModalVisble(false)
  }

  const handleSave = () => {
    // some logic to save data
    hideEditModal()
  }

  ...

  return (
    <div className="App">

      <Button onClick={displayEditModal}>Edit Name</Button>

      ...

      <EditNameModal
        visible={editModalVisible}
        name={name}
        onSave={handleSave}
        onCancel={hideEditModal}
      />
    </div>
  );
}

This is an example of a single modal rendered inside the component which needs it. As you can see, there are a lot of simple functions dedicated to this modal.

The problem is when you need more than one modal and this pattern repeats creating a mess of modal handling that is hard to track and manage.

How To Create Custom Modal/Dialog in React *Modals and Dialogs are ways you can focus users on a specific thing you want them to act upon. They are industry…*medium.com

The Solution — Modal Renderer

First, we need a way to define all our Modals in one place. We can set up a kind of modal configuration file where we register all Modals to be used.

// src/components/modals/core/modals-confg.ts

import React, {LazyExoticComponent} from "react";

export enum Modals {
 TextFieldModal,
 EditNameModal,
}

export const modalsConfig: Record<Modals, LazyExoticComponent<any>> = {
 [Modals.TextFieldModal]: React.lazy(() => import('../text-field.modal')),
 [Modals.EditNameModal]: React.lazy(() => import('../edit-name.modal'))
}

In the above, we have an enum with all modal names and a configuration object where we lazy import the component. This is very important to make sure your app does not load all the Modals at once.

This will improve the bundle size of your application and make sure that a Modal is only fetched when it needs to be used.

The “registry” handler

Next, we need something that manages what Modal is currently being displayed.

// src/components/modals/core/current-modal.ts

import {Modals, modalsConfig} from "./modal-config";

export interface CurrentModal<P> {
 name: Modals;
 props: P;
}

type Handler = (modal: CurrentModal<unknown> | null) => void;

let subs: Set<Handler> = new Set();
let modal: CurrentModal<unknown> | null;

export const currentModal = {
 subscribe(handler: Handler): () => void {
  if (typeof handler === 'function') {
   subs.add(handler);
  }

  return () => {
   subs.delete(handler);
  }
 },
 set(currentModal: CurrentModal<unknown> | null) {
  modal = currentModal;
  subs.forEach((handler) => {
   handler(modal)
  })
 },
 get(modal: Modals) {
  return modalsConfig[modal] ?? null;
 }
}

The currentModal object exposes a subscribe method we can subscribe to know which modal is currently being displayed. It also lets us set and get the modal representation CurrentModal which is simply the name and props of the Modal.

The renderer

Now we need a way to render the current Modal as it changes.

// src/components/modals/core/modal-renderer.ts

import {currentModal, CurrentModal} from "./current-modal";
import {Suspense, useEffect, useState} from "react";

export const ModalRenderer = () => {
 const [modal, updateCurrentModal] = useState<CurrentModal<any> | null>(null);

 useEffect(() => currentModal.subscribe(updateCurrentModal), []);

 if (modal) {
  const Modal = currentModal.get(modal.name);

  return <Suspense>
   <Modal {...modal?.props}/>
  </Suspense>
 }

 return null;
}

In this modal renderer, we simply subscribe to the registry and when there is a new modal, we collect it and lazy render it passing all the props it needs to be rendered with.

We can now render the modal renderer at the top of our application:

// src/app.ts
import {ModalRenderer} from "./componentes/Modals/core/modal-renderer";

export const App = () => {
  ...

  return <div id="app">
    ...
    <ModalRenderer/>
  </div>
}

Modal Trigger

Finally, we need a way to trigger modals to open and close. For that we will use a hook:

// src/components/modals/core/use-modal.ts

import {currentModal} from "./current-modal";
import {Modals} from "./modal-config";

export const useModal = <P,>(name: Modals) => {
 return {
  open: (props: P) => {
   currentModal.set({name, props: props})
  },
  close: () => {
   currentModal.set(null)
  },
 }
}

And that’s all! The hook takes the modal name and can be asserted with its prop types and returns the interface which allows us to call open and close to trigger the modal.

Now let’s improve the initial modal problem and render the example.

function App() {
  const [name, setName] = useState("");
  const editModal = useModal<EditNameModalProps>(Modals.EditNameModal);

  const displayEditModal = () => {
    editModal.open({
      name: name,
      onSave: (changedName: string) => {
        setName(changedName)
        editModal.close()
      },
      onCancel: editModal.close
    })
  }

  ...

  return (
    <div className="App">

      <Button onClick={displayEditModal}>Edit Name</Button>

      ...
    </div>
  );
}

Pros:

  • Treat Modals like functions. The editModal.open example is like calling a modal instead of rendering it;
  • Create a hook for your Modals. You can further abstract your Modals into a single hook in case they are complex or used too often in various places in the app.
const useEditNameModal = (name: string, onSave) => {
  const editModal = useModal<EditNameModalProps>(Modals.EditNameModal);

  return () => {
    editModal.open({
      name: name,
      onSave: (changedName: string) => {
        onSave(changedName)
        editModal.close()
      },
      onCancel: editModal.close
    })
  }
}
function App() {
  const [name, setName] = useState("");
  const displayEditModal = useEditNameModal(name, setName);

  ...

  return (
    <div className="App">

      <Button onClick={displayEditModal}>Edit Name</Button>

      ...
    </div>
  );
}
  • Trigger a modal to open from anywhere — hooks, non-react files, functions, etc. You can use the useModal hook inside a component but you may also import the registry from anywhere in the app and set a current modal to be displayed.
  • Better bundle size — by lazy loading the modals you avoid the need to load and render the Modal if it will never going to be used by the user.
  • Simpler Modal Render setup — No need to render a bunch of Modals inside a component and set up multiple states and state handling functions for each.
  • One-Time Setup — this is something you set up once and forget about it. The only thing you need is to add a new Modal to the Enum and config when you create them
  • Optional need to render a modal. This solution does not prevent you from rendering your Modals. It does not require you to change how your Modals are built in any way.

Cons:

  • Once a modal is rendered, it does not react to state changes from where it was called. This means that your Modals need to be pure and not depend on the caller component for anything. You call it once with everything it needs, it does its thing, and you get the result via the callback functions you passed — I’d argue that this is how all Modals should behave — just like the prompt API For Modals like this, simply render it inside the component like before.

Take Away

This solution is practical and powerful. It can also be replicated for things like Notification bars, or any global component you may have in your application.

This solution can be further improved to allow you to collect Modal history so you can go back and forward between Modals. The potential for this solution is limitless. It is up to you.

Illustration for “How to Handle Modals In A Large-Scale React App — Render Manager”

YouTube Channel: Before Semicolon Website: beforesemicolon.com

Share this article