Codementor Events

Showing a modal dialog with useImperativeHandle() React hook

Published Sep 17, 2022
Showing a modal dialog with useImperativeHandle() React hook

Take Material UI's Dialog component as an example that has open: boolean React prop as a way to manage its open/closed state. In Material UI documentation you will find a usage example similar to this:

import * as React from "react";
import { Button, Container, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";

export function Example(): JSX.Element {
  const [open, setOpen] = React.useState(false);
  const handleOpen = React.useCallback(() => setOpen(true), []);
  const handleClose = React.useCallback(() => setOpen(false), []);
  const handleAction = React.useCallback(() => { ... }, []);

  return (
    <Container>
       <Button onClick={handleOpen}>Open Dialog</Button>

       <Dialog open={state.open} onClose={handleClose}>
         <DialogTitle>...</DialogTitle>
        <DialogContent>
          ...
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose}>Cancel</Button>
          <Button onClick={handleAction}>OK</Button>
        </DialogActions>
      </Dialog>
    </Container>
  );
}

In the original example, the dialog is used in place. Normally, you want to extract dialog in a standalone component, for example:

import * as React from "react";
import { Button, Container, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@mui/material";

export function ConfirmDialog(props: ConfirmDialogProps): JSX.Element {
  const [state, setState] = ...
  const handleClose = ...
  const handleConfirm = ...

  return (
    <Dialog open={state.open} {...props}>
      <DialogTitle>...</DialogTitle>
      <DialogContent>
        ...
      </DialogContent>
      <DialogActions>
          <Button onClick={handleClose}>Cancel</Button>
          <Button onClick={handleConfirm}>OK</Button>
      </DialogActions>
    </Dialog>
  );
}

export type ConfirmDialogProps = Omit<DialogProps, "open">;

Afterwards, the original example could be reduced to the following:

import * as React from "react";
import { ConfirmDialog } from "../dialogs/ConfirmDialog.js";

export function Example(): JSX.Element {
  const handleOpen = ...
  const handleAction = ...

  return (
    <Container>
       <Button onClick={handleOpen}>Open Dialog</Button>
       <ConfirmDialog onConfirm={handleAction} />
    </Container>
  );
}

If the dialog can be used without a need to manage its state in-place that code would look nice and clean.

There are multiple ways to implement it, e.g. by introducing a top-level DialogProvider component + useDialog(...) React hook, alternatively you can add an imperative handler to the dialog itself so that it can be opened using dialogRef.current?.open() method available on the dialog instance.

import * as React from "react";
import { ConfirmDialog } from "../dialogs/ConfirmDialog.js";

export function Example(): JSX.Element {
  const dialogRef = React.useRef<DialogElement>(null);
  const handleOpen = React.useCallback(() = dialogRef.current?.open(), []);
  const handleAction = ...

  return (
    <Container>
       <Button onClick={handleOpen}>Open Dialog</Button>
       <ConfirmDialog ref={dialogRef} onConfirm={handleAction} />
    </Container>
  );
}

Now let's see how the implementation of this dialog including .open() method implemented with useImeprativeHandle(ref, ...) React hooks looks like:

import * as React from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle } from "@mui/material";

export const ConfirmDialog = React.forwardRef<
  DialogElement,
  ConfirmDialogProps
>(function ConfirmDialog(props, ref): JSX.Element {
  const { onClose, onConfirm, ...other } = props;
  const [state, setState] = React.useState<State>({ open: false });
  const handleClose = useHandleClose(setState, onClose);
  const handleConfirm = useHandleConfirm(setState, onConfirm);

  React.useImperativeHandle(ref, () => ({
    open() {
      setState({ open: true });
    },
  }));

  return (
    <Dialog open={state.open} onClose={handleClose} {...other}>
      <DialogTitle>...</DialogTitle>
      <DialogContent>...</DialogContent>
      <DialogActions>
        <Button onClick={handleClose}>Cancel</Button>
        <Button onClick={handleConfirm}>OK</Button>
      </DialogActions>
    </Dialog>
  );
});

function useHandleClose(setState: SetState, handleClose?: CloseHandler) {
  return React.useCallback<CloseHandler>(function (event, reason) {
    setState({ open: false });
    handleClose?.(event, reason ?? "backdropClick");
  }, []);
}

function useHandleConfirm(setState: SetState, handleConfirm?: ConfirmHandler) {
  return React.useCallback(async function () {
    await handleConfirm?.();
    setState({ open: false });
  }, []);
}

type State = { open: boolean; error?: Error };
type SetState = React.Dispatch<React.SetStateAction<State>>;
type CloseHandler = NonNullable<DialogProps["onClose"]>;
type ConfirmHandler = () => Promise<void> | void;

export type DialogElement = { open: () => void };

export type ConfirmDialogProps = Omit<DialogProps, "open"> & {
  onConfirm?: ConfirmHandler;
};

There are pros and cons of this approach, on the good side is that it's fully self-contained and doesn't rely on any external state management solutions.

https://github.com/kriasoft/react-starter-kit/discussions/2004

Discover and read more posts from Konstantin Tarkus
get started