Promise based modals

January 25th, 2023 - 22 minutes read

Modal is a component that appears on top of the main application content. To better visualize it, it usually comes with a backdrop. One of the most popular use cases is when we want a user to confirm some action, for example, user deletion. But it is not limited to this - it can have any content developer wanted to put in it, like form inputs.

Because modals have to be put on top of the remaining content, this introduces some challenges, like content overlapping, focus trap, etc. But this blog post will focus on showing modals and handling data flow between modals and the rest of the application.

Standard modal approach

Most popular libraries, like react-modal suggest putting modals in the same component. It can be made visible just by passing isOpen flag.

1const Wrapper = () => {
2  const [isModalOpen, setIsModalOpen] = useState(false);
3
4  return (
5    <div>
6      <button
7        onClick={() => {
8          setIsModalOpen(true);
9        }}
10      >
11        Open modal
12      </button>
13
14      <Modal isOpen={isModalOpen}>
15        <button
16          onClick={() => {
17            setIsModalOpen(false);
18          }}
19        >
20          Close Modal
21        </button>
22      </Modal>
23    </div>
24  );
25};
26

Multiple modals

As soon as we need to support more modals, issues start to appear - state variables count starts to grow, naming starts to clash, and component JSX size grows whenever a new modal is added.

1const Wrapper = () => {
2  const [isModal1Open, setIsModal1Open] = useState(false);
3  const [isModal2Open, setIsModal2Open] = useState(false);
4
5  return (
6    <div>
7      <button
8        onClick={() => {
9          setIsModal1Open(true);
10        }}
11      >
12        Open modal 1
13      </button>
14
15      <button
16        onClick={() => {
17          setIsModal2Open(true);
18        }}
19      >
20        Open modal 2
21      </button>
22
23      <Modal isOpen={isModal1Open}>
24        <button
25          onClick={() => {
26            setIsModal1Open(false);
27          }}
28        >
29          Close Modal 1
30        </button>
31      </Modal>
32
33      <Modal isOpen={isModal2Open}>
34        <button
35          onClick={() => {
36            setIsModal2Open(false);
37          }}
38        >
39          Close Modal 2
40        </button>
41      </Modal>
42    </div>
43  );
44};
45

In one case, I had 8 modals in a single component, so you can imagine how messy it looked.

Custom modal components

To mitigate the issue, modals can be moved to separate components.

But it is not so simple to do this. We cannot move Modal component to the custom component, because it has a logic, that it only renders its children when isOpen evaluates to true. The issue becomes more obvious, when we have some sort of useEffect, for example for data fetching. Then we have to add various checks that some hooks would not be executed until isOpen becomes true.

So either we leave Modal component outside, but then re-usability suffers, or move them together, but the remaining part of the code needs to be written carefully.

Dependant modals

Let's assume we have a case, where after the first modal close, the second one should be invoked. And to make it a bit more realistic, the first modal should pass some sort of result data from the first modal.

1const Wrapper = () => {
2  const [isModal1Open, setIsModal1Open] = useState(false);
3  const [modal1Result, setModal1Result] = useState<string>();
4  const [isModal2Open, setIsModal2Open] = useState(false);
5
6  return (
7    <div>
8      <button
9        onClick={() => {
10          setIsModal1Open(true);
11        }}
12      >
13        Open modal 1
14      </button>
15
16      <Modal isOpen={isModal1Open}>
17        <button
18          onClick={() => {
19            setIsModal1Open(false);
20            setModal1Result("modal 1 result");
21            setIsModal2Open(true);
22          }}
23        >
24          Close Modal 1
25        </button>
26      </Modal>
27
28      <Modal isOpen={isModal2Open}>
29        {modal1Result}
30        <button
31          onClick={() => {
32            setIsModal2Open(false);
33            setModal1Result(undefined);
34          }}
35        >
36          Close Modal 2
37        </button>
38      </Modal>
39    </div>
40  );
41};
42

Such a way of developing requires a lot of mental capacity to follow when which modal will be invoked. Also, it becomes quite hard to model the state correctly from a TypeScript perspective.

Browser prompt

When I was searching for inspiration on how to make modals more convenient, I came back to roots - native browser prompts.

1const name = prompt("Enter your name");
2
3if (!name) {
4  return;
5}
6
7const surname = prompt("Enter your surname");
8
9if (!surname) {
10  return;
11}
12
13const fullName = `${name} ${surname}`;
14

It was very straightforward code - you invoke modal, it stops the rest of the function code until a user has entered the required information, and on modal close, it either returns the value or returns null if users closed it. Then based on this value, you can continue executing the remaining part of the code.

But the main problem with native modals is that they cannot be customized - you cannot change colors, placement, or the content.

So how to get best of the both worlds - easy to use, but as well flexible to customize in any way you want?

Enter promises

We can achieve a similar API by using async/await. The main idea is to think of a way how to render a custom modal component, and that promise would resolve after the user has finished his work.

1const nameResult = await showModal({
2  component: InputModal,
3  props: { title: "Enter your name" },
4});
5
6if (nameResult.action === "CLOSE") {
7  return;
8}
9
10const surnameResult = await showModal({
11  component: InputModal,
12  props: { title: "Enter your surname" },
13});
14
15if (surnameResult.action === "CLOSE") {
16  return;
17}
18
19const fullName = `${nameResult.value} ${surnameResult.value}`;
20

When designing an API, we could resolve a promise, if user entered the value. And we could reject it if user decided to close modal. But such API becomes very limiting, because we can only have 2 possible outcomes. Also, it is annoying to write try/catch blocks everywhere.

Instead, what if modal would always resolve a promise, but resolved promise will contain the result type (or action). This allows having a variable actions count.

Another idea is that each modal could declare its own possible results set. But then how to couple modal results with promise return value?

Here again, TypeScript will play a crucial part. By utilizing TypeScript discriminated unions, we will enforce type safety for results.

Solution

Firstly, we need to store modals configurations and render modals at the end of HTML. For this task, one of the best picks is React context. The idea is to store modals in state variable as an array because it is possible to have situations where modals could be displayed on top of another modal (yes, I know it is bad from UX perspective, but life happens).

1import type { ReactNode } from "react";
2import React, { useCallback, useState } from "react";
3
4const ModalContext = React.createContext<ModalContextType | undefined>(
5  undefined
6);
7
8type Modal = {
9  id: number;
10  component: React.FunctionComponent<any>;
11  props?: { [key: string]: unknown };
12  resolve: (data: PromiseResolvePayload<"CLOSE">) => void;
13};
14
15export const ModalProvider = ({ children }: { children: ReactNode }) => {
16  const [modals, setModals] = useState<Modal[]>([]);
17
18  return (
19    <ModalContext.Provider value={{ showModal, closeModal }}>
20      {children}
21      {modals.map((modal) => {
22        const Modal = modal.component;
23
24        return (
25          <Modal key={modal.id} {...modal.props} closeModal={closeModal} />
26        );
27      })}
28    </ModalContext.Provider>
29  );
30};
31

The logic could be modified in any way - either to just support a single modal instance or if multiple modals are stored, so that just the last one would be shown (I am using this approach in React Native apps).

The Main implementation consists of 2 main functions - showModal and closeModal

1type ModalContextType = {
2  showModal<Props extends ModalProps>(options: {
3    component: React.FunctionComponent<Props>;
4    props?: Omit<Props, "closeModal">;
5  }): Promise<
6    | NonNullable<Parameters<Props["closeModal"]>[0]>
7    | PromiseResolvePayload<"CLOSE">
8  >;
9};
10
11const showModal = useCallback<ModalContextType["showModal"]>(
12  ({ component, props }) => {
13    return new Promise((resolve) => {
14      setModals((prev) => {
15        return [...prev, { component, props, resolve, id: modalId++ }];
16      });
17    });
18  },
19  []
20);
21

showModal contains the main logic for modals. It creates a new Promise instance and stores its resolve method together with component and props. This function has a few complex types because we want to link component and props. But also inherit result type from Modal component props.

1type ModalContextType = {
2  closeModal(data?: PromiseResolvePayload<"CLOSE">): void;
3};
4
5const closeModal = useCallback<ModalContextType["closeModal"]>((data) => {
6  setModals((prev) => {
7    const newModals = [...prev];
8    const lastModal = newModals.pop();
9    lastModal?.resolve(data || { action: "CLOSE" });
10    return newModals;
11  });
12}, []);
13

closeModal is usually invoked from within modals themselves. It removes the last modal, takes its previously stored promise resolve function, and resolves it with an action which was passed from modal. As promise is resolved, the code block where showModal was called and awaited, will finally resolve, and result-based logic will be executed.

This is a combined version previous smaller parts:

1import type { ReactNode } from "react";
2import React, { useCallback, useState } from "react";
3
4export type ModalProps = {
5  closeModal: (param?: PromiseResolvePayload<"CLOSE">) => void;
6};
7
8type PromiseResolvePayload<Action extends string> = { action: Action };
9
10type ModalContextType = {
11  showModal<Props extends ModalProps>(options: {
12    component: React.FunctionComponent<Props>;
13    props?: Omit<Props, "closeModal">;
14  }): Promise<
15    | NonNullable<Parameters<Props["closeModal"]>[0]>
16    | PromiseResolvePayload<"CLOSE">
17  >;
18  closeModal(data?: PromiseResolvePayload<"CLOSE">): void;
19};
20
21const ModalContext = React.createContext<ModalContextType | undefined>(
22  undefined
23);
24
25let modalId = 1;
26
27type Modal = {
28  id: number;
29  component: React.FunctionComponent<any>;
30  props?: { [key: string]: unknown };
31  resolve: (data: PromiseResolvePayload<"CLOSE">) => void;
32};
33
34export const ModalProvider = ({ children }: { children: ReactNode }) => {
35  const [modals, setModals] = useState<Modal[]>([]);
36
37  const showModal = useCallback<ModalContextType["showModal"]>(
38    ({ component, props }) => {
39      return new Promise((resolve) => {
40        setModals((prev) => {
41          return [...prev, { component, props, resolve, id: modalId++ }];
42        });
43      });
44    },
45    []
46  );
47
48  const closeModal = useCallback<ModalContextType["closeModal"]>((data) => {
49    setModals((prev) => {
50      const newModals = [...prev];
51      const lastModal = newModals.pop();
52      lastModal?.resolve(data || { action: "CLOSE" });
53      return newModals;
54    });
55  }, []);
56
57  return (
58    <ModalContext.Provider value={{ showModal, closeModal }}>
59      {children}
60      {modals.map((modal) => {
61        const Modal = modal.component;
62
63        return (
64          <Modal key={modal.id} {...modal.props} closeModal={closeModal} />
65        );
66      })}
67    </ModalContext.Provider>
68  );
69};
70

useModal hook

To ease the use of the context, it is a good practice to create a dedicated hook.

1import { useContext } from "react";
2
3export const useModal = () => {
4  const context = useContext(ModalContext);
5
6  if (!context) {
7    throw new Error("useModal cannot be used outside ModalContext.Provider");
8  }
9
10  return context;
11};
12

The modal component can be of any type/library you want. The only crucial part is closeModal property passed from ModalContext. It is the glue that accepts the result - an object which usually consists of an action string and payload.

At the same time, it is being used by TypeScript to infer possible result types. It is preferred to model params using discriminated unions so that later it would be easy to narrow types down.

1type InputModalProps = {
2  title: string;
3  closeModal: (
4    param?: { action: "CLOSE" } | { action: "CONFIRM"; value: string }
5  ) => void;
6};
7
8const InputModal = ({ title, closeModal }: InputModalProps) => {
9  return (
10    <ModalWrapper closeModal={closeModal}>
11      <h5>{title}</h5>
12      <Button
13        variant="secondary"
14        onClick={() => {
15          closeModal({ action: "CLOSE" });
16        }}
17      >
18        CLOSE
19      </Button>
20      <Button
21        variant="primary"
22        onClick={() => {
23          closeModal({ action: "CONFIRM", value: "input value" });
24        }}
25      >
26        CONFIRM
27      </Button>
28    </ModalWrapper>
29  );
30};
31
32export default InputModal;
33

Drawbacks

But this approach is not without issues.

The most noticeable one is stale props. It means that when showModal function is invoked, a props snapshot is taken and it is not possible to update them. To be more exact - it is possible, but then code becomes more complex, and promise resolving logic needs to be improved.

A similar problem applies to variables after the modal has been resolved. You might expect some functions or variables to be updated, but they contain the value that was present during the time modal was created. Similar issues happen with React and memoized values.

For simple modals, most of the time, you will not encounter any of the previously mentioned issues. They become more noticeable as complexity grows. But still, by utilizing refs, or by splitting responsibilities more precisely, these issues could be solved/avoided.

Summary

The idea of combining React with Promises opens up many new possibilities how to improve the code. Promise based modal approach helps to simplify applications significantly. Previously used state variables are gone and data flow becomes linear and easier to track.