Good React Modals

elsdoerfer
5 min readSep 2, 2017

--

A Modal Dialog.

This is not about a new modal library. Instead, I'd like to talk about about some specific issues I struggled with over the two years or so I have been using React, and the approach I ultimately settled on.

What kind of issues? There are two we will discuss: Not rendering a modal content while it is invisible while doing proper fade-out animations and managing , and side-loading data for the modal while having a proper loading indicator.

Let's first have a look at a simple modal such you might write when you first pull a library like react-modal into your project:

function PickGroupDialog(props) {
const {isOpen, onRequestClose} = props;
return <Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<select />
</Modal>
}
class Screen extends Component { state = {
dialogIsOpen: false
}
render() {
return <div>
<PickGroupDialog
isOpen={this.state.dialogIsOpen}
onRequestClose={this.handleDialogClose}
/>
<button onClick={this.handleDialogOpen}>Edit</button>
</div>
}
handleDialogOpen = () => {
this.setState({dialogIsOpen: true});
}
handleDialogClose = () => {
this.setState({dialogIsOpen: false});
}
}

Apart from being verbose, this works pretty well at first, but I quickly find myself trying to solve these problems:

  1. The modal contents are rendered even if the dialog is closed. One of the reasons this is problematic is because to render the content, you might be accessing properties that are not yet set by the owner.
  2. It's difficult to side-load data for the modal if you want to show a proper loading indicator.
  3. (Also, did I mention how painfully verbose it is?)

To see these issues in action, let's extend the code above a little bit.

function PickGroupDialog(props) {
const {isOpen, onRequestClose, user, groups} = props;
return <Modal isOpen={isOpen} onRequestClose={onRequestClose}>
Pick group for user: {user.name}
<Select items={groups} />
</Modal>
}
PickGroupDialog = loadGroups()(PickGroupDialog);
class Screen extends Component {
state = {
selectedUser: null
}
render() {
return <div>
<PickGroupDialog
isOpen={!!this.state.selectedUser}
onRequestClose={this.handleDialogClose}
user={this.state.selectedUser}
/>
{Users.map(user => <li>
<button onClick={() => this.setState({selectedUser: user}) }>Edit {user.name}</button>
</li>)}
</div>
}
}

We are now passing a user object to the dialog. We are also wrapping the modal with a data loader. This code as a couple of problems:

  1. It will actually crash, because on the initial render, Screen.state.selectedUer isnull, but the dialog's render function still runs, and tries to access the users .name property.
  2. While loadGroups is loading data from the server, we don't show a loading indicator in the UI. The normal way a data-loading HoC might show an indicator — rendering it instead of the children — doesn't work here.

Let's look at these separately, and discuss potential solutions:

1 — The modal content renders even when the modal is invisible.

So, in the example above, we could just check if theuser property has been passed, and accommodate for the case that it's not. That will get old soon if you have a bunch of these properties.

A version of this idea is to just hide the modal completely if it's not supposed to be open:

function PickGroupDialog(props) {
const {isOpen, onRequestClose} = props;
if (!isOpen) {
return null;
}
return <Modal isOpen={isOpen} onRequestClose={onRequestClose}>
....
</Modal>
}

This kind of works, except:

  • Does your <Modal> include a hide animation? This animation is now broken, because you abruptly remove the whole component from the tree.
  • The less invasive version, checking only if the user property is passed suffers from a less severe version of the same problem. When the dialog is closed from Screen, you will likely also set the user property to null. While the modals hide animation is running, you can sometimes see that change on the screen: elements like the username disappearing.
  • As a general note, let's consider that rendering a bunch of invisible dialogs for no reason has a performance impact.
  • There is also the issue of state. If your modal has state, you may want to reset that once the dialog is closed. If your PickGroupDialog component holds state, it won't be reset as long as PickGroupDialog remains in the component tree. You know have to manage this state reset manually via componentWillReceiveProps 🤢.
  • And we didn't even talk about our use of the data loading HoC. loadGroups will load the data even before the modal is ever opened.

2 —Showing a loading indicator for the HoC.

If your modal needs to load data when it opens, and you want to show a loading indicator (you probably do), there are really three ways you can go:

  • You show the indicator inside the modal.
  • You show the indicator on/next to the button that was clicked.
  • You show the indicator globally, maybe a NProgress-like top bar.

If those, the last one is potentially the easiest, because you can implement this without considering your modal implementation. The loadGroups HoC shows above would just have to communicate up to the global progress bar during loading.

Let's talk about solutions

I think the way to solve this is using the function-as-a-child pattern. Image your modal component has an API like this:

function PickGroupDialog(props) {
const {isOpen, onRequestClose, user} = props;
return <Modal isOpen={isOpen} onRequestClose={onRequestClose}>
{
() => <div>
<input value={user.name} />
</div>
}
</Modal>
}

This solves a couple of our problems:

  • The content will not be rendered when the modal is not open, so you don't have to worry about theuser prop being null, or about performance issues.
  • When the modal is closed, the <Modal> component will extend it's existing hack and continue to render the old children, with the old props, during the out-animation.

But not all of them:

  • If PickGroupDialog holds state, it still will not be reset.
  • If we want to wrap PickGroupDialog inside a data-loading HoC, this HoC still has no way to render a loading indicator inside the modal.

Another attempt

The reason we are still having these issues is that our component, PickGroupDialog sits on top of all the sweet modal magic, so the data loading and state is above the modal too. We'd have to move it one level down:

function PickGroupDialogUI(props) {
const {user, groups} = props;
return <div>
Pick group for user: {user.name}
<Select items={groups} />
</div>
}
PickGroupDialogUI = loadGroups()(PickGroupDialogUI);
function PickGroupDialog(props) {
const {isOpen, onRequestClose, ...rest} = props;
return <Modal isOpen={isOpen} onRequestClose={onRequestClose}>
{() => <PickGroupDialogUI {...props}>}
</Modal>
}

Any state inside PickGroupDialogUI will now be reset if the dialog closes. If loadGroups shows a loading indicator while loading is ongoing, this will appear inside the modal.

And while it seems quite painful to separate every dialog into two components, we can create our own modal HoC:

function PickGroupDialogUI(props) {
const {user, groups} = props;
return <div>
Pick group for user: {user.name}
<Select items={groups} />
</div>
}
PickGroupDialogUI = loadGroups()(PickGroupDialogUI);
const PickGroupDialog = modal(PickGroupDialogUI);

I've been using this approach for some time now, and it's been working super well. I apologize for not posting a ready-to-use code snippet here, but if you are facing these issues, I hope that this discussion helped you with building your own solution.

--

--