paint-brush
The Perfect React Modal Implementation for 2023by@fedor
20,454 reads
20,454 reads

The Perfect React Modal Implementation for 2023

by Fedor UsakovMay 25th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Yeah, life is short, so if you feel like it, you may jump straight into the code. I’ve set up a basic project on codesandbox, and you can find the full source code here: https://codesandbox.io/s/angry-monad-4cmnyy The code should speak for itself, at least that’s how I intended it. Note that you can choose between using Redux or React Context to keep state.
featured image - The Perfect React Modal Implementation for 2023
Fedor Usakov HackerNoon profile picture

Modals are an integral part of front-end development. Whether you're building a compact mobile app or a sprawling enterprise-level UI, modals are a very efficient way to grab users’ attention.


Once the design prototypes are ready, it’s time to get coding. So you will open your favorite IDE and create a SomethingModal.tsx file or pick whatever other name strikes your fancy.


If you're anything like me, you won't settle for anything less than perfection. This means you’ll want to create not just a single modal window, but a scalable and reusable subsystem that can be easily integrated into your app and that will make your team members sing you praises.


And so you should, especially if you expect to add more modal windows to your app in the future. Having the subsystem in place will enable you to implement concise opening and closing (in terms of lines of code used), display multiple modals at once, prioritize one modal over another, store state, and pass properties to the modal components.


Requirements

Keeping all that in mind let's set the requirements:

  1. Each modal window should have a unique ID.

  2. We’ll use React hooks syntax for opening and closing modal windows, as it is a modern and concise way to add app-wide side effects.

  3. We’ll use Redux where we will keep our modal state and metadata.

  4. To render our modal independently from the main app DOM hierarchy we’ll utilize React Portal.

  5. And of course, we'll integrate with react-router to give us a backup way to show and hide modals.


Our goal is to write code that works efficiently without unnecessary rerenders:


// ideal world modal usage example:

import { useModal } from 'src/hooks'

const MyComponent = (props: MyComponentProps) => {  
  const { show, hide } = useModal("myUniqModalID")

  const handleOpenModal = ({ props: ModalProps }) => show(meta)

  return (    
    <div>
      <h1>Our main view JSX code</h1>
      <button onClick={handleOpenModal}>Open Modal</button>
    </div>  
  )
}


TL;DR


Yeah, life is short, so if you feel like it, you may jump straight into the code.

I’ve set up a basic project on codesandbox, and you can find the full source code here:

The code should speak for itself, at least that’s how I intended it. Note that you can choose between using Redux or React Context to keep state.


Implementation

So let's start building our super-vip-gucci-fancy modals infrastructure from scratch, step by step.


You might be surprised, but the first question we need to answer isn't when Bitcoin will rise again. Instead, we need to figure out how to identify the modal components. How do we know which modal to open when we have several available in our app?

Using the Filename as a Unique ID with Lazy Loading and Suspense

Here’s what I suggest: we already have a unique component ID and this is the component source filename. Let’s use it.


Of course, technically we might have two or more files with the exact same name in our codebase.


Sure we can, but why should we do that? Let’s be sensible, remember that it is not considered good practice (according to the global React community), and avoid keeping components with the same file names in our repo.


Avoid keeping components with the same file names in the single project


Let's be good devs and assume that:

  • The source code of each modal window consists of one component;
  • The component is located in a single .js/.tsx file;
  • The file has a unique name;
  • We may also agree to use an internal file naming convention, such as [Purpose/Domain]Modal.tsx (i.e. LoginModal.tsx or OrdersListModal.tsx)
  • All of our modal components will be housed in a single folder, i.e. ../components/modals/
  • This folder will also contain a separate folder for each modal with the same name as the component. In the end, the full path should look like: ../components/modals/LoginModal/LoginModal.tsx


So, with all that in mind, we might come up with the following piece of logic:

interface ILazyComponentProps {
   filename: string
}

function LazyComponent({ filename }: ILazyComponentProps) {
   const handleModalClose = () => console.warn('Hereby I promise to close this modal!')

   const Component = React.lazy(() => import(`./${filename}/${filename}.tsx`))

   return (
       <Suspense fallback={null}>
           <ErrorBoundary>
               {filename ? (
                   <Component onClose={handleModalClose} />
               ) : null}
           </ErrorBoundary>
       </Suspense>
   )
}


Alright, so what is actually going on here? We created a universal wrapper <LazyModal /> component that will lazily load any of our modal components using their filenames as IDs and wrap them in framework provided service components <ErrorBoundary /> and <Suspense />for better app safety and easier debugging.

The code will import a given component into a depersonalized <Component /> and if anything goes wrong, we’ll see an error message from the custom <ErrorBoundary /> component. The code of this component is not really that interesting, so I won’t go into much detail here, but you can check it out later if you want.


Suspense is one of the latest React features that have been created to manage the asynchronous component flow and its main job is to display a fallback until its children have finished loading. To learn more about Suspense, check out the new React documentation portal (https://beta.reactjs.org/reference/react/Suspense).


Modal Provider

Next we need to render this universal modal component somehow. One way is to follow the famous “provider” pattern that is widely used in packages like redux or react-intl

The idea is simple: we just need to wrap our <App /> in the <ModalProvider /> component that will take care of the rendering.


interface IModalProviderProps {
    children: React.ReactNode
}
// for now we will just hardcode our modals IDs and state
// later we will use this data model with Redux to make modals state available through the app
const MODALS: ModalMap = {
 'TestModal': {
   id: 'TestModal',
   open: true
 },
 'LoginModal': {
   id: 'LoginModal',
   open: false,
   meta: {
     user: 'fedor'
   }
 }
}


function ModalProvider(props: IModalProviderProps) {

    const modals = Object.keys(MODALS).filter((id) => MODALS[id].open)

    return (
        <>
            {modals.map((filename) => (
                <LazyComponent key={filename} filename={filename} />
            ))}
            {props.children}
        </>
    )
}


It's a straightforward implementation with only one responsibility — to lazily render modal components based on their state that we mocked in the MODALSconstant.

In the component, we have a list of modals (filenames acting as IDs) that should be shown simultaneously, and our only goal is to render them along with all child components wrapped inside our provider.

Later we will fetch our modals IDs and metadata from the Redux store, but for now, to make things simple I just hardcoded two modal IDs/filenames - TestModal and LoginModal.

Here I will use a very basic data model - ModalMap, which should suffice for this demo and can be easily expanded in the future to suit specific needs.


export interface ModalMeta {
 [name: string]: any
}

export type Modal = {
 id: string
 open: boolean
 meta?: ModalMeta
}

export type ModalMap = {
 [id: string]: Modal
}


Somewhere in our index.tsx we will use our <ModalProvider /> this way:


/* file src/index.tsx */

ReactDOM.render(
    <Provider store={store}>
        <ModalProvider>
            <App />
        </ModalProvider>
    </Provider>,
    document.querySelector('#root')
) 


To make things work, we also have to create two folders and two components in the same folder where <LazyComponent />is situated:

./TestModal/TestModal.tsx and 

./LoginModal/LoginModal.tsx

Thus <LazyComponent /> will be able to locate these files and load them lazily on demand.


React Portal and Basic Modal component


Following the component composition best practices we will first create a universal <BaseModal /> component and then create our specialized ‘Login’ and ‘Test’ modal components.

The requirements for our BaseModal are pretty simple:

  • Look cool;
  • Use React Portal to be rendered independently from our main app DOM hierarchy;
  • Get closed with a simple tap on the modal window;
  • Include a title, the body, and a footer.

To achieve this, we'll use React Portal. Here is the piece of code that meets all of our needs:


import { memo } from 'react'
import { createPortal } from 'react-dom'
import classnames from 'classnames'
import './modals.css'


export interface IBaseModalProps {
  show: boolean
  title: string
  children: string | React.ReactNode
  footer?: string | React.ReactNode
  closeOnTap?: boolean
  onClose?: () => void
}

export const BaseModal = memo((props: IBaseModalProps) => {
  const { title, footer, closeOnTap, onClose, children } = props

  const root = document.getElementById('root')

  if (!root) throw new Error('Root node not found. Cannot render modal.')

  const handleInsideClick: React.MouseEventHandler<HTMLDivElement> = (
    event
  ) => {
    if (!closeOnTap) {
      event.stopPropagation()
    }
  }

  return createPortal(
    <div
      className={classnames({
        Modal: true,
        'Modal-show': props.show
      })}
      onClick={onClose}
    >
      <div className="Modal-panel" onClick={handleInsideClick}>
        <div>
          <h4 className="Modal-header">{title}</h4>
        </div>
        <div className="Modal-content">{children}</div>
        {footer && <div className="Modal-footer">{footer}</div>}
      </div>
    </div>,
    root
  )
})


We will use CSS modules and import all our styling from modals.css file.


I won't include all of the CSS source code here, but you may check it out by yourself:

[

]


And here is an example of how to create a basic TestModal component:


// ./TestModal/TestModal.tsx

import { BaseModal } from '../BaseModal'

export interface ITestModalProps {
 onClose?: () => void
}

export default function TestModal(props: ITestModalProps) {
 return (
   <BaseModal title="Test Modal" show closeOnTap>
     I'm a test modal window, so why don't you close me?
   </BaseModal>
 )
}


Please note that we need to use default export here, otherwise the lazy loading won't work properly. If you try to lazy load the named export, it will fail. The <ErrorBoundary /> we created will throw an error saying ‘Something went wrong’ and the browser console will inform you that the lazy loading has failed.


Here’s what React’s official documentation says: "... currently only supports default exports. If the module you want to import uses named exports, you can create an intermediate module that reexports it as the default. This ensures that tree shaking keeps working and that you don’t pull in unused components."


For details please refer here: [https://reactjs.org/docs/code-splitting.html#named-exports].

Modal Windows State

Now we've got two modal windows that can be rendered (or not) based on the static state we have in our MODALS constant stored in the <ModalProvider /> component. You can play around with it by changing the data.


For example, you could set MODALS["LoginModal"].open to true and this will result in two modals being rendered simultaneously. Or you could set both modals .openproperty to false and there will be no windows rendered.

To take control of this situation and tell our app which modals should be shown or hidden, we have to make this state available globally and integrate it with some global app state management. We could use a library like Redux or React Context subsystem.


My aim is to illustrate both approaches because depending on the app size, Redux can be considered overhead. Personally, I like the idea of making modals work using only React's built-in capabilities, but ...


Redux is tough

Redux Version

Talking about the basics of Redux or discussing its pros and cons is out of the scope of this article. Let's assume that you are already familiar with this library, or will be in the near future. You can check out the official documentation here: [Redux].


To get started, I'd recommend reading through the fundamentals first, which you can find here: [Redux Fundamentals, Part 1: Redux Overview]\

Here are the requirements that we may have for this piece of code:


  • We need at least two functions (actions) for opening and closing the window by its ID.
  • Also we need to store our ModalMap data model somewhere in the global state, which we'll call the "store". For the purposes of this article, we’ll create a single simplified reducer function.
  • To use this data in our app we'll also need some selector functions that will read data from the store and return it in the proper format.


In a real-world project, I’d recommend having a general reducer for managing all of your app's UI state, called something like ui. An ui.modal could be the perfect subkey in this reducer data model to store all of your modal windows state and data.


// ./store/reducer.ts

type ModalActions = modalOpenAction | modalCloseAction;

export interface UIState {
  modal: ModalMap;
}

const initialState: UIState = {
  modal: {}
};

export default function (
    state: UIState = initialState, 
    action: ModalActions
): UIState {
  switch (action.type) {
    case MODAL_OPEN: {
      const id = action.payload.modalFileName;
      const meta = action.payload.meta;
      return {
        ...state,
        modal: {
          ...state.modal,
          [id]: { id, meta, open: true }
        }
      };
    }

    case MODAL_CLOSE: {
      const id = action.payload.modalFileName;
      return {
        ...state,
        modal: {
          ...state.modal,
          [id]: { id, open: false }
        }
      };
    }

    default:
      return state;
  }
}


We have a simple map object (initialState is basically a snapshot of this data model) that keeps all our modal window data normalized. To open the modal, we need to change the state with the MODAL_OPEN action that will use the modal window filename as a unique key. To close a modal window, we’ll send an action MODAL_CLOSEwith the same key.

Once again I want to point out that using the filename of the modal component is a good idea as it guarantees that the key is unique.

The code for our actions and action creators is also simplified to the very basics:


// ./store/action.ts

import { ModalMeta } from '../typings/modals';

export const MODAL_OPEN = 'MODAL_OPEN';
export const MODAL_CLOSE = 'MODAL_CLOSE';

export type modalOpenAction = {
  readonly type: typeof MODAL_OPEN;
  readonly payload: {
    modalFileName: string;
    meta: ModalMeta;
  };
};

export type modalCloseAction = {
  readonly type: typeof MODAL_CLOSE;
  readonly payload: {
    modalFileName: string;
  };
};

export function openModal(
  modalFileName: string,
  meta: ModalMeta
): modalOpenAction {
  return {
    type: MODAL_OPEN,
    payload: {
      modalFileName,
      meta
    }
  };
}

export function closeModal(modalFileName: string): modalCloseAction {
  return {
    type: MODAL_CLOSE,
    payload: { modalFileName }
  };
}


To connect all of this together we have to modify our index.ts:


// index.ts

import React from 'react';
import ReactDOM from 'react-dom/client';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import App from './App';
import { ModalProvider } from './components/modals';
import uiReducer from './store/reducer';

const rootElement = document.getElementById('root')!;
const root = ReactDOM.createRoot(rootElement);

const store = createStore(uiReducer);

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <ModalProvider>
        <App />
      </ModalProvider>
    </Provider>
  </React.StrictMode>
);


Essentially, we are creating the store instance using Redux createStore() routine from our single reducer function. Afterwards, we’re wrapping the entire application code with <Provider />component, also provided by Redux, to make the store available to all components in the hierarchy.

The next step is to extract data from the store using selector functions. This data will be required by our<ModalProvider /> component as well as the useModal() React hook that will open each of our modal windows.


// ./store/selector.ts

import { ModalMeta } from '../typings/modals';
import type { UIState } from './reducer';

export const getModalsList: (state: UIState) => string[] = (state) =>
  Object.keys(state.modal).filter((modalId) => state.modal[modalId].open);

export const isModalOpen: (state: UIState, id: string) => boolean = (
  state,
  id
) => state.modal[id]?.open ?? false;

export const getModalMeta: (
  state: UIState,
  id: string
) => ModalMeta | undefined = (state, id) => state.modal[id]?.meta;


Nothing fancy here. We just need to get a list of all the modals we have so we can render it in <ModalProvider /> , and we also need to know if a window is currently open or has any meta data, which is crucial for the <LazyComponent />component.

With these selector functions in place, we can now modify our<ModalProvider />


// ./components/modal/ModalProvider.tsx

export function ModalProvider(props: IModalProviderProps) {
 // now we getting the list of all the modals from the Redux store
 const modals = useSelector(getModalsList);

  return (
    <>
      {modals.map((filename) => (
        <LazyComponent key={filename} filename={filename} />
      ))}
      {props.children}
    </>
  );
}


Instead of having an array of string filenames aka ID`s from constant data model we now get this list from the Redux store. To ensure that everything works as before, simply update ./store/reducer.ts with the following two lines of code.


// ./store/reducer.ts
...

const MODALS: ModalMap = {
  TestModal: {
    id: 'TestModal',
    open: true
  },
  LoginModal: {
    id: 'LoginModal',
    open: true,
    meta: {
      user: 'fedor'
    }
  }
};
...

const initialState: UIState = {
  modal: MODALS
};


So good so far!


After populating the store with our constant sample data model, we can see that windows are visible and ready.

However, for the next iteration, it is better to revert these changes as we will be using the useModal hook going forward.


Our nice looking minimalistic Modal Windows


**Using Hook (It’s 2023, Folks!)
**

As time passes, things tend to evolve — this is what we’ve learned from the teachings of Mr. Darwin. Since we approach the year 2567in the Buddhist calendar, we need to keep up with the latest and greatest features that React has to offer, especially given the convenience and beauty of their use.

To bring our modal ecosystem up-to-date with modern React features, we'll be implementing a useModal hook, which will serve as our primary tool for opening modal windows.


// ./components/modal/useModal.ts

import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { openModal, closeModal } from '../../store/action';
import { UIState } from '../../store/reducer';
import { isModalOpen } from '../../store/selector';
import { ModalMeta } from '../../typings/modals';

export function useModal(modalFileName: string) {
  const dispatch = useDispatch();

  const onOpen = useCallback(
    (meta: ModalMeta) => dispatch(openModal(modalFileName, meta)),
    [modalFileName]
  );
  const onClose = useCallback(
() => dispatch(closeModal(modalFileName)), [modalFileName]);

  const isOpen = useSelector<UIState>((state) =>
    isModalOpen(state, modalFileName)
  );

  return {
    isOpen,
    onOpen,
    onClose
  };
}


Here it is, our beautiful React hook! Inside it, we create TWO memoized actionsonOpen and onClose, with an isOpen flag that indicates whether a window is open or closed. To avoid creating new function instances on each call, both actions are wrapped in the useCallback hook. To check if the window is already open or closed, we call the previously created selector,  isModalOpen().

All that's left is to return these actions and state from the hook, so that developers can open the modal window, close it, and check its state.

Just in case, here's a simple diagram that briefly illustrates how everything works:


Things get a bit complex at this point, so this diagram should briefly illustrate how everything works


This is an example of how it can be used:


// App.tsx
…

export default function App() {
  const { onOpen: openLoginModal } = useModal('LoginModal');
  const { onOpen: openTestModal } = useModal('TestModal');

  return (
    <div className="App">
      <h1>Hello User</h1>
      <h2>Here is our magnificent enterprise level App!</h2>
      <div style={{ display: 'flex', gap: '2rem' }}>
        <button onClick={openLoginModal}>LOGIN</button>
        <button onClick={openTestModal}>TEST</button>
      </div>
    </div>
  );
}


Conclusion

So that's basically it. We have a working example of a slightly overthought modal ecosystem based on modern React features, with a simple developer-friendly API. There is still room for improvement of course. We have to ensure that there are no unnecessary rerenders happening with React profiling tools, test our selectors' code, and add memoization if necessary.


We may even add a react-router package to this project and open/close modal windows based on the current route path.

To be continued…

Part V, VI and VII *with React Context, new **HTML5 ***<dialog /> element and react-router will be published as independent blog posts…