In Prod

← Back to Posts

Custom Alert Dialog in React

April 13, 2020

TL;DR

Completed code is at the bottom.

Every modern browser has two functions on the window object, alert and confirm, that allow developers to show a standardised prompt to users.

The alert function shows a simple blocking popup that contains important information, such as "This is not allowed.". The confirm function requires an input from the user in the form of a yes/no decision ("OK", "Cancel"), such as "Do you wish to proceed?", and then returns a boolean result.

These are super handy and easy to implement:

index.tsx

_11
<button
_11
onClick={() => alert("This is a bad idea")}
_11
>
_11
Launch Nukes
_11
</button>
_11
_11
<button
_11
onClick={() => confirm("Are you sure!?") ? launch() : beSensible()}
_11
>
_11
Launch them anyway!
_11
</button>

In chrome this looks like the following:

chrome-alert
chrome-confirm

This is great as it's easy and functional, however, it's not pretty and we are at the mercy of the browser for styling and functionality. The rest of this post is a walkthrough on creating a drop-in replacement using react function components and typescript.


Alert in React

To take control of style and functionality, and make the dialogs be more on-brand, we can recreate this feature in React and turn it into a reusable drop-in component.

For this example I will be using Material-UI (MUI) to do most of the heavy lifting for styling and modal functionality.

The first thing we need is a component to instantiate:

index.tsx

_32
// set our material theme to dark mode
_32
const theme = createMuiTheme({
_32
palette: {
_32
type: "dark"
_32
}
_32
});
_32
_32
const AlertRoot: React.FC = () => {
_32
return (
_32
// pass in our dark theme
_32
<ThemeProvider theme={theme}>
_32
{/* Sets an absolute root component as a clickaway handler and
_32
a container for content, which is appended to our root element */}
_32
<Dialog
_32
onClose={() => console.log("CANCEL")}
_32
open={true}
_32
disablePortal={true}
_32
>
_32
{/* content components */}
_32
<DialogTitle>Title</DialogTitle>
_32
<DialogContent>
_32
<DialogContentText>A message!</DialogContentText>
_32
</DialogContent>
_32
<DialogActions>
_32
<Button onClick={() => console.log("OK!")}>
_32
{"OK"}
_32
</Button>
_32
</DialogActions>
_32
</Dialog>
_32
</ThemeProvider>
_32
);
_32
}

Above is a basic function component that displays a MUI dialog when rendered. It contains a title, a message and an "OK" button that will eventually clear the component when clicked.

Append the Component

We now need a way to instantiate our component as we want the new product to be as close to alert as we can.

NOTE: This is not really a react-y way of doing this as we are directly manipulating the DOM, but it works. I would love to know of a more react focused way of achieving the same result, i.e. calling a function to render a temporary blocking component that returns a promise result.

index.tsx

_20
// an ID to give our root element, can be what ever you like
_20
const rootID = "alert-dialog";
_20
_20
// a base function that appends our component to the document body
_20
function Create() {
_20
// see if we have an element already
_20
let div = document.getElementById(rootID);
_20
if (!div) {
_20
div = document.createElement("div");
_20
div.id = rootID;
_20
document.body.appendChild(div);
_20
}
_20
// render with react
_20
ReactDOM.render(<AlertRoot />, div);
_20
}
_20
_20
// expose a function that we can reuse in our apps to call the alert
_20
export function Alert() {
_20
Create();
_20
}

We could now call our Alert function in a component and we would get something like the following:

index.tsx

_3
<Button onClick={() => Alert("")}>
_3
{"Alert"}
_3
</Button>

alert-initial

Adding Data

Now lets update our component and exported function so that we can provide a message and if needed, a title. First we need to update our exported function:

index.tsx

_23
// add a message and title parameter. title is optional and defaults to "Alert"
_23
export function Alert(message: string, title: string = "Alert") {
_23
// we pass these parameters to the create function
_23
Create(message, title);
_23
}
_23
_23
// our create function now accepts the message and title variables
_23
function Create(message: string, title: string) {
_23
let div = document.getElementById(rootID);
_23
if (!div) {
_23
div = document.createElement("div");
_23
div.id = rootID;
_23
document.body.appendChild(div);
_23
}
_23
_23
// and we pass them to our component as props
_23
ReactDOM.render(
_23
<AlertRoot
_23
message={message}
_23
title={title}
_23
/>, div
_23
);
_23
}

With our functions updated, we now need to update our component to accept the new props and display the values:

index.tsx

_32
// add an interface that defines the new props
_32
export interface IAlertProps {
_32
message: string;
_32
title: string;
_32
}
_32
_32
// update the component to accept the props of our interface type
_32
const AlertRoot: React.FC<IAlertProps> = (props) => {
_32
// destructure the props for easier access
_32
const { message, title, confirm } = props;
_32
_32
return (
_32
<ThemeProvider theme={theme}>
_32
<Dialog
_32
onClose={() => console.log("CANCEL")}
_32
open={true}
_32
disablePortal={true}
_32
>
_32
{/* add the new prop values to our title and message containers */}
_32
<DialogTitle>{title}</DialogTitle>
_32
<DialogContent>
_32
<DialogContentText>{message}</DialogContentText>
_32
</DialogContent>
_32
<DialogActions>
_32
<Button onClick={() => console.log("OK!")}>
_32
{"OK"}
_32
</Button>
_32
</DialogActions>
_32
</Dialog>
_32
</ThemeProvider>
_32
);
_32
}

We can now update our call of Alert to take a message and optional title:

index.tsx

_3
<Button onClick={() => Alert("Our new message!", "Relevant Title")}>
_3
{"Alert"}
_3
</Button>

alert-newprops

Closing the Dialog

The last piece we need to add to make the component complete is a close function. As our component is controlled from outside our root app and is self contained, to close it, we can just remove it from the DOM. For this we need a reference to our root element, which we will hold in a useRef hook. On mount we will get and store a reference to the element and on close, remove the element:

index.tsx

_15
// add the following after the props destructuring
_15
_15
// our root element ref
_15
const root = useRef<HTMLDivElement | null>();
_15
_15
// on mount, get and set the root element
_15
useEffect(() => {
_15
let div = document.getElementById(rootID) as HTMLDivElement;
_15
root.current = div;
_15
}, [])
_15
_15
// provide a function that remove the root element from the DOM
_15
function Close() {
_15
root.current?.remove();
_15
}

We can then update our Dialog and Button components to call Close() when dismissed:

index.tsx

_18
<ThemeProvider theme={theme}>
_18
<Dialog
_18
onClose={() => Close()}
_18
open={true}
_18
disablePortal={true}
_18
>
_18
{/* add the new prop values to our title and message containers */}
_18
<DialogTitle>{title}</DialogTitle>
_18
<DialogContent>
_18
<DialogContentText>{message}</DialogContentText>
_18
</DialogContent>
_18
<DialogActions>
_18
<Button onClick={() => Close()}>
_18
{"OK"}
_18
</Button>
_18
</DialogActions>
_18
</Dialog>
_18
</ThemeProvider>

We now have a fully functional drop-in replacement Alert() that can be and fully customised to remain brand-centric.


Extending with Confirm

To be able to get the users response to a confirmation prompt, we need to extend our component to await an input and return the result.

We can now add a new exported function for Confirm and extend our Create function and our components props. To do this we create a new Promise<boolean> on mount and resolve it with a boolean result when a use clicks the button, which is then returned to the calling function.

The result is our alert pops with OK and Cancel buttons and awaits input. Once selected the result is sent back to the calling function and is allowed to continue. Bear in mind that the Confirm implementation is now an async/await operation as we need to return the promise result. The Alert function is not a promise so it can be dropped in as a direct replacement.

index.tsx

_129
// completed code for Alert and Confirm
_129
import { Button, createMuiTheme, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, ThemeProvider } from "@material-ui/core";
_129
import React, { useEffect, useRef } from 'react';
_129
import ReactDOM from "react-dom";
_129
_129
const theme = createMuiTheme({
_129
palette: {
_129
type: "dark"
_129
}
_129
});
_129
_129
// type of alert to trigger
_129
enum AlertType {
_129
ALERT,
_129
CONFIRM
_129
}
_129
_129
// extended with the type prop
_129
export interface IAlertProps {
_129
message: string;
_129
title: string;
_129
type: AlertType;
_129
}
_129
_129
const rootID = "alert-dialog";
_129
_129
// our promise to be stored for resolve on input
_129
let returnResponse: (value: boolean) => void;
_129
_129
const AlertRoot: React.FC<IAlertProps> = (props) => {
_129
// include type
_129
const { message, title, type } = props;
_129
const root = useRef<HTMLDivElement | null>();
_129
_129
useEffect(() => {
_129
let div = document.getElementById(rootID) as HTMLDivElement;
_129
root.current = div;
_129
}, [])
_129
_129
function Close() {
_129
root.current?.remove();
_129
}
_129
// called on OK
_129
function Confirm() {
_129
// only resolve if confirm type
_129
if (type === AlertType.CONFIRM) {
_129
// returns true
_129
returnResponse(true);
_129
}
_129
Close();
_129
}
_129
// called on cancel/dismiss
_129
function Cancel() {
_129
// only resolve if confirm type
_129
if (type === AlertType.CONFIRM) {
_129
// returns false
_129
returnResponse(false);
_129
}
_129
Close();
_129
}
_129
_129
return (
_129
<ThemeProvider theme={theme}>
_129
<Dialog
_129
// Cancel on dismiss
_129
onClose={() => Cancel()}
_129
open={true}
_129
disablePortal={true}
_129
>
_129
<DialogTitle>{title}</DialogTitle>
_129
<DialogContent className={styles.content}>
_129
<DialogContentText>{message}</DialogContentText>
_129
</DialogContent>
_129
<DialogActions>
_129
<Button
_129
color={"secondary"}
_129
// confirm on ok
_129
onClick={() => Confirm()}
_129
>
_129
{"OK"}
_129
</Button>
_129
{/* only display cancel if confirm type */}
_129
{
_129
type === AlertType.CONFIRM &&
_129
<Button
_129
// cancel on cancel
_129
onClick={() => Cancel()}
_129
>
_129
{"Cancel"}
_129
</Button>
_129
}
_129
</DialogActions>
_129
</Dialog>
_129
</ThemeProvider>
_129
);
_129
}
_129
_129
// pass in alert type
_129
function Create(message: string, title: string, type: AlertType = AlertType.ALERT) {
_129
let div = document.getElementById(rootID);
_129
if (!div) {
_129
div = document.createElement("div");
_129
div.id = rootID;
_129
document.body.appendChild(div);
_129
}
_129
_129
ReactDOM.render(
_129
<AlertRoot
_129
message={message}
_129
title={title}
_129
type={type}
_129
/>, div
_129
);
_129
}
_129
_129
// new confirm method
_129
export function Confirm(message: string, title: string = "Confirm") {
_129
// pass in type
_129
Create(message, title, AlertType.CONFIRM);
_129
// set our promise for resolve on input
_129
return new Promise<boolean>(resolve => {
_129
returnResponse = resolve;
_129
});
_129
}
_129
_129
export function Alert(message: string, title: string = "Alert") {
_129
// pass in type
_129
Create(message, title, AlertType.ALERT);
_129
}

Which can be implemented as follows:

index.tsx

_9
<Button
_9
onClick={async () =>
_9
await Confirm("Are you sure?")
_9
? console.log("TRUE")
_9
: console.log("FALSE")
_9
}
_9
>
_9
{"Confirm"}
_9
</Button>

confirm

Andrew McMahon
These are a few of my insignificant productions
by Andrew McMahon.