Good React Modals
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:
- 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.
- It's difficult to side-load data for the modal if you want to show a proper loading indicator.
- (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:
- 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. - 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 theuser
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 asPickGroupDialog
remains in the component tree. You know have to manage this state reset manually viacomponentWillReceiveProps
🤢. - 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 the
user
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.