How to integrate modern HTML5 dialogs with React

HTML5 offers a cool <dialog> element that offers improved accessibility and mobile device support, but it might be tricky to combine with React, because of imperative style of controlling such modals.

Let’s start with a simple example…

import React, { useRef } from "react";
import ReactDOM from "react-dom/client";

const App: React.FC = () => {
    const dialogRef = useRef<HTMLDialogElement>(null);
    
    return (
        <>
            <button onClick={() => dialogRef.current?.showModal()}>
                Open dialog
            </button>

            <dialog ref={dialogRef}>
                <h2>Dialog title</h2>
                <p>Dialog content</p>
                <button onClick={() => dialogRef.current?.close()}>Close</button>
            </dialog>
        </>
    );
};

const rootEl = document.getElementById("root");
if (rootEl) {
    const root = ReactDOM.createRoot(rootEl);
    root.render(
        <React.StrictMode>
            <App/>
        </React.StrictMode>
    );
}

If you run this simple React app using any bundler (Webpack, Vite, Rsbuild etc) you get something like this:

Pretty good for single-component app without any CSS styles, isn’t it? We have modal dialog that displays on top of anything on the page, dims the whole page content and closes by ESC button or Back button on mobile devices (it’s pretty hard, almost impossible in reliable way to achieve last thing without <dialog>). We can style dialog itself by adding CSS class to it, we can style backdrop by adding CSS rules for dialog::backdrop selector. But in this article we’ll use nice and simple CSS framework called PicoCSS to avoid diving into the design and focus on React code.

Let’s install @picocss/pico NPM package using your favorite Node package manager and continue:

//import "@picocss/pico/scss/pico.scss"; // You can import SCSS and customize it via variables
import "@picocss/pico/css/pico.css"; // You can use different color themes
import React, { useRef } from "react";
import ReactDOM from "react-dom/client";

const App: React.FC = () => {
    const dialogRef = useRef<HTMLDialogElement>(null);
    
    return (
        <main className="container">
            <article>
                <button onClick={() => dialogRef.current?.showModal()}>
                    Open dialog
                </button>
            </article>

            <dialog ref={dialogRef}>
                <article>
                    <header>Dialog title</header>
                    <p>Dialog content</p>
                    <footer>
                        <button onClick={() => dialogRef.current?.close()}>Close</button>
                    </footer>
                </article>
            </dialog>
        </main>
    );
};

...

As you can see we’ve adjusted HTML layout to use PicoCSS page structure and styles. Now we have some fancy design:

We can see some common dialog structure - title, content, buttons (they could be represented differently with different CSS frameworks, but we almost always have such parts in any modal). We could create a separate component for modals, but we’ll need to somehow either expose HTMLDialogElement reference in order to be able to open and close it programmatically or handle showModal/close invocation inside the component. React suggests us the second approach. Let’s implement it!

import React, { type PropsWithChildren, useEffect, useRef } from "react";

interface ModalProps extends PropsWithChildren {
    shown?: boolean;
    title?: string;
    primaryButtonText?: string;
    onPrimaryButtonClick?: () => void;
    secondaryButtonText?: string;
    onSecondaryButtonClick?: () => void;
    onClose?: () => void;
}

const Modal: React.FC<ModalProps> = ({
    shown,
    title,
    primaryButtonText,
    onPrimaryButtonClick,
    secondaryButtonText,
    onSecondaryButtonClick,
    onClose,
    children
}) => {
    const dialogRef = useRef<HTMLDialogElement>(null);
    
    useEffect(() => {
        if (shown) {
            if (!dialogRef.current?.open) {
                dialogRef.current?.showModal();
            }
        } else {
            if (dialogRef.current?.open) {
                dialogRef.current?.close();
            }
        }
    }, [shown]);
    
    return (
        <dialog ref={dialogRef} onClose={onClose}>
            <article>
                {title !== undefined && (<header>{title}</header>)}
                {children}
                {(primaryButtonText !== undefined || secondaryButtonText !== undefined) && (
                    <footer>
                        {primaryButtonText !== undefined && (<button onClick={onPrimaryButtonClick}>
                            {primaryButtonText}
                        </button>)}
                        {secondaryButtonText !== undefined && (<button className="secondary" onClick={onSecondaryButtonClick}>
                            {secondaryButtonText}
                        </button>)}
                    </footer>
                )}
            </article>
        </dialog>
    );
};

We define common layout for all dialogs in our app with optional title and up to two buttons (it’s always possible to ignore these buttons and define your own if two buttons is not enough).

Let’s use our freshly defined dialog component!

import React, { useState } from "react";

const App: React.FC = () => {
    const [dialogShown, setDialogShown] = useState(false);
    
    return (
        <main className="container">
            <article>
                <button onClick={() => setDialogShown(true)}>
                    Open dialog
                </button>
            </article>

            <Modal
                shown={dialogShown}
                title="Are you sure?"
                primaryButtonText="Confirm"
                onPrimaryButtonClick={() => setDialogShown(false)}
                secondaryButtonText="Cancel"
                onSecondaryButtonClick={() => setDialogShown(false)}
                onClose={() => setDialogShown(false)}
            >
                <p>
                    You're going to close this dialog.
                </p>
            </Modal>
        </main>
    );
};

We can see React-way with controlled dialog visibility state.

We could finish there, but it doesn’t seem convenient when you think about real applications. Imagine how confirmation dialogs are used? Probably some button onClick handler, then, in case of successful response we’re performing action. We’ll need to define separate dialog for each message variant, we’ll mount as much dialog instances as number of components which are going to trigger it. Imagine if we have a list with “Delete” button for each row with confirmation dialog before real deletion. We’ll mount as many dialogs as list rows (of course we can mount dialog at list component itself, but then we’ll need to pass dialog trigger upward and maybe pass response downward that would be quite a mess). Imagine if we would like to trigger chain of modals (e.g. ask for confirmation, then show alert about success/error).

Good old alert/prompt would play pretty well there. What if our dialogs could be used like this?

Let’s try! We’ll define a context provider that will hold list of active dialogs and provide showModal function which accepts dialog component type and props. TypeScript magic will help us to check prop types and dialog result type.

interface ManagedModalProps<T> {
    shown?: boolean;
    onClose?: (result?: T) => void;
}

interface ModalItem<T, P extends ManagedModalProps<T>> {
    id: number;
    component: React.ComponentType<P>;
    props: P;
    callback: (value?: T) => void;
}

type ShowModalFunction = <P extends ManagedModalProps<any>>(
    component: React.ComponentType<P>,
    props?: Omit<Omit<P, "shown">, "onClose">
) => Promise<P extends ManagedModalProps<infer T> ? T : never>;

interface ModalContextData {
    showModal: ShowModalFunction;
}

const ModalContext = createContext<ModalContextData>({
    showModal: () => {
        throw new Error("No ModalProvider present");
    }
});

const ModalProvider: React.FC<PropsWithChildren> = ({children}) => {
    const nextModalId = useRef(0);
    const [modals, setModals] = useState<ModalItem<any, any>[]>([]);
    
    const ctx = useMemo<ModalContextData>(() => {
        const showModal: ShowModalFunction = (
            component,
            props
        ) => new Promise(resolve => {
            setModals(modals => {
                const id = nextModalId.current++;
                return [
                    ...modals,
                    {
                        id,
                        component,
                        props,
                        callback: (result) => {
                            setModals(modals =>
                                modals.filter(modal => modal.id !== id)
                            );
                            resolve(result);
                        }
                    }
                ];
            });
        });
        return {showModal};
    }, []);
    
    return (
        <ModalContext.Provider value={ctx}>
            {children}
            {modals.map(({id, component: Component, props, callback}) => (
                <Component key={id} {...props} shown={true} onClose={callback} />
            ))}
        </ModalContext.Provider>
    );
};

const useShowModal = (): ShowModalFunction => useContext(ModalContext).showModal;

Now we can define generic confirmation dialog.

interface ConfirmDialogProps extends ManagedModalProps<boolean> {
    title?: string;
    message?: string;
    confirmButtonText?: string;
    cancelButtonText?: string;
}

const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
    shown,
    title,
    message,
    confirmButtonText,
    cancelButtonText,
    onClose
}) => {
    return (
        <Modal
            shown={shown}
            title={title}
            primaryButtonText={confirmButtonText}
            onPrimaryButtonClick={() => onClose?.(true)}
            secondaryButtonText={cancelButtonText}
            onSecondaryButtonClick={() => onClose?.(false)}
            onClose={onClose}
        >
            <p>{message}</p>
        </Modal>
    );
};

Defining different dialog types is easy: you just use Modal component, forward shown and onClose to it and also invoke onClose with result when you want (e.g. you can put <form> inside the dialog and then call onClose with form data in its onSubmit handler).

Let’s update our App component and wrap it in ModalProvider:

const App: React.FC = () => {
    const showModal = useShowModal();
    
    const handleButtonClick = async () => {
        const res = await showModal(ConfirmDialog, {
            title: "Are you sure?",
            message: "This action cannot be undone",
            confirmButtonText: "Procced",
            cancelButtonText: "Cancel"
        });
        if (res) {
            await showModal(ConfirmDialog, {
                title: "Action completed",
                message: "The operation was successfully performed",
                confirmButtonText: "Got it"
            });
        } else {
            await showModal(ConfirmDialog, {
                title: "Action cancelled",
                message: "The operation was successfully cancelled",
                confirmButtonText: "Got it"
            });
        }
    };
    
    return (
        <main className="container">
            <article>
                <button onClick={handleButtonClick}>
                    Open dialog
                </button>
            </article>
        </main>
    );
};

const rootEl = document.getElementById("root");
if (rootEl) {
    const root = ReactDOM.createRoot(rootEl);
    root.render(
        <React.StrictMode>
            <ModalProvider>
                <App/>
            </ModalProvider>
        </React.StrictMode>
    );
}

Now you can test the whole application: when you click the button, you got confirmation dialog that followed by either success or cancel alerts depending on which button you’ve clicked.

You can gather user input from dialogs (imagine prompt dialog with text input or more sophisticated form), call REST APIs in between etc.

The final application code:

import "@picocss/pico/css/pico.css";
import React, { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom/client";

interface ModalProps extends PropsWithChildren {
    shown?: boolean;
    title?: string;
    primaryButtonText?: string;
    onPrimaryButtonClick?: () => void;
    secondaryButtonText?: string;
    onSecondaryButtonClick?: () => void;
    onClose?: () => void;
}

const Modal: React.FC<ModalProps> = ({
    shown,
    title,
    primaryButtonText,
    onPrimaryButtonClick,
    secondaryButtonText,
    onSecondaryButtonClick,
    onClose,
    children
}) => {
    const dialogRef = useRef<HTMLDialogElement>(null);
    
    useEffect(() => {
        if (shown) {
            if (!dialogRef.current?.open) {
                dialogRef.current?.showModal();
            }
        } else {
            if (dialogRef.current?.open) {
                dialogRef.current?.close();
            }
        }
    }, [shown]);
    
    return (
        <dialog ref={dialogRef} onClose={onClose}>
            <article>
                {title !== undefined && (<header>{title}</header>)}
                {children}
                {(primaryButtonText !== undefined || secondaryButtonText !== undefined) && (
                    <footer>
                        {primaryButtonText !== undefined && (<button onClick={onPrimaryButtonClick}>
                            {primaryButtonText}
                        </button>)}
                        {secondaryButtonText !== undefined && (<button className="secondary" onClick={onSecondaryButtonClick}>
                            {secondaryButtonText}
                        </button>)}
                    </footer>
                )}
            </article>
        </dialog>
    );
};

interface ManagedModalProps<T> {
    shown?: boolean;
    onClose?: (result?: T) => void;
}

interface ModalItem<T, P extends ManagedModalProps<T>> {
    id: number;
    component: React.ComponentType<P>;
    props: P;
    callback: (value?: T) => void;
}

type ShowModalFunction = <P extends ManagedModalProps<any>>(
    component: React.ComponentType<P>,
    props?: Omit<Omit<P, "shown">, "onClose">
) => Promise<P extends ManagedModalProps<infer T> ? T : never>;

interface ModalContextData {
    showModal: ShowModalFunction;
}

const ModalContext = createContext<ModalContextData>({
    showModal: () => {
        throw new Error("No ModalProvider present");
    }
});

const ModalProvider: React.FC<PropsWithChildren> = ({children}) => {
    const nextModalId = useRef(0);
    const [modals, setModals] = useState<ModalItem<any, any>[]>([]);
    
    const ctx = useMemo<ModalContextData>(() => {
        const showModal: ShowModalFunction = (
            component,
            props
        ) => new Promise(resolve => {
            setModals(modals => {
                const id = nextModalId.current++;
                return [
                    ...modals,
                    {
                        id,
                        component,
                        props,
                        callback: (result) => {
                            setModals(modals =>
                                modals.filter(modal => modal.id !== id)
                            );
                            resolve(result);
                        }
                    }
                ];
            });
        });
        return {showModal};
    }, []);
    
    return (
        <ModalContext.Provider value={ctx}>
            {children}
            {modals.map(({id, component: Component, props, callback}) => (
                <Component key={id} {...props} shown={true} onClose={callback} />
            ))}
        </ModalContext.Provider>
    );
};

const useShowModal = (): ShowModalFunction => useContext(ModalContext).showModal;

interface ConfirmDialogProps extends ManagedModalProps<boolean> {
    title?: string;
    message?: string;
    confirmButtonText?: string;
    cancelButtonText?: string;
}

const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
    shown,
    title,
    message,
    confirmButtonText,
    cancelButtonText,
    onClose
}) => {
    return (
        <Modal
            shown={shown}
            title={title}
            primaryButtonText={confirmButtonText}
            onPrimaryButtonClick={() => onClose?.(true)}
            secondaryButtonText={cancelButtonText}
            onSecondaryButtonClick={() => onClose?.(false)}
            onClose={onClose}
        >
            <p>{message}</p>
        </Modal>
    );
};

const App: React.FC = () => {
    const showModal = useShowModal();
    
    const handleButtonClick = async () => {
        const res = await showModal(ConfirmDialog, {
            title: "Are you sure?",
            message: "This action cannot be undone",
            confirmButtonText: "Procced",
            cancelButtonText: "Cancel"
        });
        if (res) {
            await showModal(ConfirmDialog, {
                title: "Action completed",
                message: "The operation was successfully performed",
                confirmButtonText: "Got it"
            });
        } else {
            await showModal(ConfirmDialog, {
                title: "Action cancelled",
                message: "The operation was successfully cancelled",
                confirmButtonText: "Got it"
            });
        }
    };
    
    return (
        <main className="container">
            <article>
                <button onClick={handleButtonClick}>
                    Open dialog
                </button>
            </article>
        </main>
    );
};

const rootEl = document.getElementById("root");
if (rootEl) {
    const root = ReactDOM.createRoot(rootEl);
    root.render(
        <React.StrictMode>
            <ModalProvider>
                <App/>
            </ModalProvider>
        </React.StrictMode>
    );
}

Feel free to adapt and use it in your next app!

P.S.: <dialog> element is supported by all modern browsers, but it’s worth to mention that Internet Explorer doesn’t support it. You might need polyfill if you target IE.

Home