RootUI
dialogService

Dialog Service

A window of content placed on top of the viewport, rendering the content underneath inert.

See useDialog for full documentation on hook usage.

Doing something different

Problem

Dialogs/modals are commonly written as components. Rendering these dialogs typically involves managing their "display" state in the parent component and passing callbacks like onSubmit to it. While this works, the parent component must manage the dialog's state and behavior, which results in duplicated logic across all consumers of it. Even if you write an abstraction for this functionality, this state management is still required for all consumers... and it's cumbersome.

Solution

The dialogService aims to solve these problems by providing tooling that allows them to be extremely reusable with minimal boilerplate. Opening a dialog is as easy as calling a function. The dialog opens in a DialogEntry component rendered in the root of your application.

The ultimate goal is ease of reusability. Writing your dialog in a custom hook affords all of the above:

// abstraction
function useCustomDialog() {
  return useDialog(() => <div>Hello, World!</div>);
}

// implementation
function ParentComponent() {
  const myDialog = useCustomDialog();
  return <Button onClick={() => myDialog()}>Open dialog</Button>;
}

Promise-based API

The dialogService handles opening/closing with promises. This means that when you open a dialog, you can await its result. The state of when a dialog closes is completely in the hands of the dialog itself.

In this example, the dialog is a yes/no confirmation:

function useConfirmDialog() {
  return useDialog(() => {
    const { closeDialog } = useDialogContext();
    return (
      <>
        <p>Are you sure?</p>
        <button onClick={() => closeDialog(true)}>Yes</button>
        <button onClick={() => closeDialog(false)}>No</button>
      </>
    );
  });
}

We can now await the response of the dialog - no state changes, no callbacks,... just a simple await:

function MyComponent() {
  const myDialog = useConfirmDialog();

  async function openDialog() {
    if (await myDialog()) {
      console.log("Confirmed");
    } else {
      console.log("Denied");
    }
  }

  return <button onClick={openDialog}>Open Dialog</button>;
}

This does 2 things:

  1. It separates the dialog into its own component for easy reusability.
  2. It pulls all state and callback management out of the parent component. The parent component has no idea what the child component does, except what it should output.

Outside a component

Sometimes dialogs need to be rendered outside a component. The useDialog hook is just a wrapper around the dialogService.open function.

const result = await dialogService.open(() => <div>Hello, World!</div>);

Internally, the service will keep track of this dialog with a dynamically generated id. In some rare cases, you might want to force close the dialog from the parent which will require passing in an id to the options object. Reuse this id to close it:

const id = "super-id";
// open
dialogService.open(() => <div>Hello, World!</div>, undefined, { id });
// close
dialogService.close(id);

Methods

open

open<T>(
  DialogComponent: React.FC<T>,
  props?: T,
  options: {
    id?: string;
    size?: "sm" | "md" | "lg";
  } = {},
): Promise

Open a dialog using a passed-in component, props, and options. A promise is returned that resolves when the dialog closes.

function MyComponent({ name }: { name: string }) {
  return <button onClick={myDialog}>Open Dialog</button>;
}

const result = await dialogService.open(
  MyComponent,
  { name: "John Doe" },
  { size: "sm", id: "custom-id" },
);

close

close(id: string, result: unknown): void

Manually close the dialog from the parent. Requires the id of the dialog to close.

dialogService.close("custom-id", "result");

closeAll

closeAll(): void

Close all open dialogs.

dialogService.closeAll();