Skip to content

How to write a promise-style dialog?

Published at  at 04:40 PM

Background

Looking at various popular UI libraries, all Modal/Dialog components almost always have the same API as the native dialog. Taking antd as an example:

function someService(data: any) {
  return fetch("https://httpbin.org/delay/1", {
    body: JSON.stringify(data),
    method: "POST",
    mode: "cors",
  });
}

const App: React.FC = () => {
  const [open, setOpen] = useState(false);
  const [confirmLoading, setConfirmLoading] = useState(false);

  const showModal = () => {
    setOpen(true);
  };

  const handleOk = async () => {
    setConfirmLoading(true);
    try {
      console.log(`Logging in with:`, values);
      await someService(values);
      message.success("Login successful");
      setOpen(false);
    } catch (error) {
      message.error(error.message);
    }
    setConfirmLoading(false);
  };

  return (
    <>
      <Button type="primary" onClick={showModal}>
        Open Modal
      </Button>
      <Modal title="Title" open={open} onOk={handleOk} confirmLoading={confirmLoading}>
        <Form>{/* FormItems */}</Form>
      </Modal>
    </>
  );
};

As you can see, the state variables and the function calls to update them are very trivial.

Existing optimization solutions:

Reusing the same Modal in a list

A very common practice, but if you look closely, there are actually many states and methods involved. If there are multiple Modal + Form combinations, it would be a disaster (abstracting into components requires another way of writing, and the number of state variables will not decrease but increase).

import React, { useState } from "react";
import { Form, Modal, Space, Table, Input, InputNumber, message } from "antd";
import type { TableProps } from "antd";

interface DataType {
  key: string;
  name: string;
  age: number;
}

function someService(data: DataType) {
  return fetch("https://httpbin.org/delay/1", {
    body: JSON.stringify(data),
    method: "POST",
    mode: "cors",
  });
}

const data: DataType[] = [
  {
    key: "1",
    name: "John Brown",
    age: 32,
  },
  {
    key: "2",
    name: "Jim Green",
    age: 42,
  },
];

const App: React.FC = () => {
  const [form] = Form.useForm<Omit<DataType, "key">>();
  const [open, setOpen] = useState(false);
  const [confirmLoading, setConfirmLoading] = useState(false);
  const [edittingModel, setEditModel] = useState<DataType | null>(null);

  useEffect(() => {
    if (open) {
      if (edittingModel) {
        form.setFieldsValue({
          name: edittingModel.name,
          age: edittingModel.age,
        });
      } else {
        form.resetFields();
      }
    }
  }, [open, edittingModel]);

  const handleOk = async () => {
    setConfirmLoading(true);
    try {
      const values = await form.validateFields();
      await someService({ ...values, key: edittingModel.key });
      message.success("submit successful");
      setOpen(false);
    } catch (error) {
      message.error(error.message);
    }
    setConfirmLoading(false);
  };

  const columns: TableProps<DataType>["columns"] = [
    // ...
    {
      title: "Actions",
      key: "actions",
      render: (_, record) => (
        <Space>
          <a
            onClick={() => {
              setOpen(true);
              setEditModel(record);
            }}
          >
            Edit
          </a>
          <a>Delete</a>
        </Space>
      ),
    },
  ];

  return (
    <>
      <Table columns={columns} dataSource={data} />
      <Modal
        title="Title"
        open={open}
        onOk={handleOk}
        onCancel={() => {
          setOpen(false);
        }}
        confirmLoading={confirmLoading}
        forceRender
      >
        <Form form={form} name="modaledit" labelCol={{ span: 6 }} wrapperCol={{ span: 12 }}>
          {/* FormItems */}
        </Form>
      </Modal>
    </>
  );
};

export default App;

Procomponents.ModalForm

It’s pretty good, but its API is fixed to a ModalForm paired with a Button, which becomes a bit inelegant when dealing with several buttons in each row of the list.

const columns: TableProps<DataType>["columns"] = [
  // ...
  {
    title: "Actions",
    key: "actions",
    render: (_, record) => (
      <Space>
        {/* Each line renders a Modal + Form. */}
        <ModalForm<DataType>
          trigger={<a>Edit</a>}
          form={form}
          submitTimeout={2000}
          onFinish={async values => {
            await someService(values);
            message.success("submit successful");
            return true;
          }}
        >
          {/* FormItems */}
        </ModalForm>
        <a>Delete</a>
      </Space>
    ),
  },
];

Modal.method()

A very ideal method, it does not require maintenance of visible/confirmLoading, but the official does not support form scenarios, and the props are also a castrated version.

A “New” Idea

It’s actually not “new,” just rarely used by people.

Think back to the code const name = window.prompt("please type a name"), it is very simple, there is no need to maintain any visible/confirmLoading properties, nor is there a need to declare any model/dialog. Why can’t we use such an API? It’s really just replacing an input with a form.

Fantasy Time (API Design)

Is there a possibility that a requirement expressed in one sentence should be solvable with just a piece of code, without the need for so many state variables?

For example, the above requirement can be summarized in one sentence as: “Clicking the ‘edit’ button opens a pop-up form, after the user modifies and confirms the form it is submitted with a loading effect, and upon successful submission, the pop-up closes; if submission fails, it does not close.”

The ideal code would be as follows:

const columns: TableProps<DataType>["columns"] = [
  // ...
  {
    title: "Actions",
    key: "actions",
    render: (_, record) => (
      <Space>
        <a
          onClick={() => {
            await createModal<DataType>({
              title: "Edit",
              maskClosable: false,
              children: <Form initialValues={{ name: record.name }}>{/* FormItems */}</Form>,
              async onOk(values) {
                await someService({ ...values, key: record.key });
                message.success("submit successful");
              },
              async onFailed(error) {
                message.error(error.message);
              },
            });
          }}
        >
          Edit
        </a>
        <a>Delete</a>
      </Space>
    ),
  },
];

The answer is yes. Let’s talk about the implementation and some problems encountered:

Basic function implementation

TODO: you can check source code first:

Some problems and optimizations

Context Providing

Since the Modal and its content is a new ReactElement every time it is created, it is not the same Root instance as the whole application, so the Modal and its content can’t get the Context in the application, which has been bothering me for a long time.

modalRender

What I initially thought was to use ModalProps.modalRender to pass in all the required Context Providers:

createModal<DataType>({
  title: "Edit",
  maskClosable: false,
+ modalRender: (modal) => <ContextProviders>{modal}</ContextProviders>,
  children: <Form initialValues={{ name: record.name }}>{/* FormItems */}</Form>,
  async onOk(values) {
    await someService({ ...values, key: record.key });
    message.success("submit successful");
  },
});

Then encapsulate a higher-order function to fill in some default parameters and general parameters to simplify the code. However, this also has disadvantages: not all ContextProvider are at the Root level, if there is a page-level ContextProvider, you have to write modalRender again, which is very inconvenient.

The initial version was like this, but I always thought there was a better solution. As a result, I always felt that the project was unfinished and did not promote it.

export function createFunctionWithDefaultProps<T, R = void>(defaultParams: CreateModalProps<T, R>) {
  const newFunction: typeof createModal<T, R> = params => createModal<T, R>({ ...defaultParams, ...params });
  return newFunction;
}
contextHolder

Later on, I referred to the official Modal.useModal() of antd and directly mounted the newly created ReactElement under Root, so there is no need to deal with Context anymore:

const Demo: React.FC = () => {
  const [contextHolder, createModal] = useModalCreation({
    // optional default params
    maskClosable: false,
    okText: "Submit",
    maskStyle: {
      opacity: 0.8,
    },
  });
  return (
    <RootContainer>
      <Button
        onClick={() => {
          createModal({
            title: "Some title",
            content: "You can see that the i18n and theme configuration works now",
          });
        }}
      >
        Context Providing
      </Button>
      {contextHolder}
    </RootContainer>
  );
};

I didn’t understand the principle at first, but after some time when I looked at it again, I suddenly got it. It’s actually quite simple:

contextHolder is essentially a ReactNode[]. When createModal() was called, all we need to do is add <Modal><Form>{children}</Form></Modal> into the array (of course there’s also an appropriate time to remove it). The user just needs to render this ReactNode[] in their page component and that’s it.

It was like a sudden realization, as if everything became clear, like being enlightened with profound knowledge - brilliant!

rootComponent

Something like shadcn toast is also a good design, but I think it’s similar to the contextHolder solution, I’ll update it when I have time.

Double clicking the button brings up two Modal

Since it is a functional call every time, we only need to add a throttle. The time interval only needs to take the animation time of Modal, because when the pop-up animation of Modal ends, the mask will cover the button.

Finally

The implementation method introduced in this article has been open-sourced and published to npm, and everyone is welcome to use it directly.

Based on this idea, you can easily create various pop-up components such as Drawer, ImagePreview, Notification, etc.

I once submitted a PR to antd, but unfortunately it was closed.

Share on: