A (Simple) Modal Management Option for React Native
Isaac C.
Posted on May 13, 2023
We talking about modals
Your basic modals aren't that difficult to build. For a simple modal, you'll want to be able to open it and close it. You'll probably use some form of isVisible
or open
prop set to true/false, and maybe some neat animations so that the modal slides/fades when it enters or exits the user's view. But the modal itself isn't the focus of this article. The focus is on how we manage those modals across the application.
In my case, I joined a team, and we started this process of refactoring large portions of the application. And I realized that it might be beneficial to take this time and revisit and refactor the modal management logic. The logic worked, but it wasn't in a style we wanted to maintain. And after expressing this, it was agreed that I could refactor it.
Originally, I wanted to adopt an approach that I had experienced in a previous role. But I couldn't remember the exact implementation details (maybe a sign that they were well-hidden hahaha).
I did remember that we might have used some form of portal, and I considered doing the same. The problem is that React Native doesn't exactly support portals (out of the box), which is understandable. I could have (and probably easily) used a package and commenced, but we were in the middle of a major refactor and no one was thrilled to add a new package. So, I had to do this from scratch, only using native React Native tools.
The Requirements
The first thing we did was list our base-level requirements:
- Context should handle the modal opening/closing - Some form of context should hold the internal logic for how the modal is shown.
- Hook as an Interface - Components should use a hook as an interface for opening a modal (i.e. the component shouldn't directly use the context)
- Pass Custom Data - We should have the ability to pass data (we'll call props) to the hook and have those props be received by the rendered modal
- Support Custom and Default modals - The consumer can pass/request their own custom modal if they need to. And if they don't specify a modal, a default modal is loaded and will be rendered with the props passed to it.
Those were the "Final 4" of our absolute needs. The bonuses would have been 5) Easy animation handling, and 6) Support for modal-in-modal (I know, this is bad hahaha but sometimes you're asked for it, and pushing back doesn't work š¤·š¾āāļø). But we really just wanted something clear, and easy to use, that would last if we couldn't get around to refactoring it in the near future.
And that's when I found this article suggesting something similar to what we wanted. But, for us, it wasn't exactly what we wanted. And we opted not to fully adopt it. But the overall approach was in the right direction. So we took the core pieces and made some slight modifications. And below is how that looked...
Building the Context
First, we built the context
import React, { createContext, useCallback, useState } from 'react';
import { DEFAULT_MODAL } from '@components/CustomModal'
const ModalContext = createContext();
const DEFAULT_PROPS = {
content: null,
description: '',
primaryCTA: null,
secondaryCTA: null,
shouldCenterTitle: true,
shouldShowClose: true,
title: '',
};
const ModalProvider = ({ children }) => {
const [modalName, setModalName] = useState('');
const [modalProps, setModalProps] = useState(DEFAULT_PROPS);
const openModal = ({ modalName: mName = DEFAULT_MODAL, props }) => {
if (mName) {
setModalName(mName);
setModalProps({ ...modalProps, ...props });
}
};
const closeModal = () => setModalName('')
const value = {
closeModal,
modalName,
modalProps,
openModal,
};
return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>;
};
export { ModalProvider, ModalContext };
Let's review this. The context is storing 2 pieces of state and exposing 4 things:
- The context stores the name of the modal. You can think of this a bit like a key in an object
- The context also stores the data that is intended to be passed to the modal, which we call
modalProps
- And the 4 things this context exposes are: a) The ability to open the modal (by passing as an argument, the name of the modal, and the props intended to be passed to the modal) b) The ability to close the modal (wherein the Context will remove the name) c) The name of the modal set in state d) The props (object) to be passed to the modal
Remember our (overall) goal was to get the 4 requirements listed above. And this context got us there. But it's not the only thing we would need.
Building the adjacent Hook
The context was our core, but we didn't want everyone digging into that core directly. We wanted an interface, and that's where the hook came in. So we built this:
import { useContext } from 'react';
import { ModalContext } from '@context/ModalContext';
export const useModal = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error('useModal must be used within a ModalProvider');
}
return context;
};
This hook is again, trying to be as simple as possible, it's really just returning the context, and only throwing an error if we're operating outside of the provider.
Adding the Provider to the App
Speaking of the Provider, before we forget, let's be sure we have that setup. Here's what we did, and it should look familiar.
import { CustomModal } from '@components/CustomModal'
export const App = () => {
return (
<ModalProvider>
<CustomModal /> {/** Continue reading (please)... **/}
{...YOUR_OTHER_COMPONENTS_AND_PROVIDERS_ETC}
</ModalProvider>
)
}
The Actual Modal Component
At this point, you might be asking, "Where in the world is the actual modal that gets rendered? I've seen ModalContext's that conditionally render the modal inside of it, where's your modal?" And you'd be 100% right to ask those questions. Here it is:
import React, { useCallback } from 'react';
import { TransactionModal } from '@modals/TransactionModal';
import { FunUserModal } from '@modals/FunUserModal';
import { useModal } from '@hooks/useModal';
export const DEFAULT_MODAL = 'a string I like';
export const FUN_MODAL = 'another one of my favorite strings';
export const MODAL_LIST = {
[DEFAULT_MODAL]: TransactionModal,
[FUN_MODAL]: FunUserModal,
};
export const CustomModal = () => {
const { closeModal, modalName, modalProps } = useModal();
const renderModal = useCallback(() => {
const Component = MODAL_LIST[modalName];
return (
<Component
{...modalProps}
closeModal={closeModal}
isOpen={!!modalName}
/>
);
}, [closeModal, modalName, modalProps]);
if (!modalName) {
return null;
}
return renderModal();
};
We can read through this together. And we can start with, where is this getting rendered. This component is rendered in 1 place, in the App.js (as you've seen above), this gets rendered nowhere else. Let's review the section before the component, and then get into the actual component.
Checking out our own little "static store"
At the top of this component, we have a simple key in the format below.
const NAME_OF_MODAL_TO_BE_USED_EVERYWHERE = "string-that-will-only-be-used-as-the-value-of-the-key"
Then we have the object holding the location of the Modals, in the format below. You can think of this as a "static store" (i.e. it's not dynamic on its own, you have to add and remove components and change the keys manually). This object's keys are the above consts, and the key's values are the references to actual React components.
const LIST_OF_ALL_MODALS = {
[NAME_OF_MODAL_TO_BE_USED_EVERYWHERE]: ActualReactJSComponent
}
Diving into the component
Next, we have the actual component. At the top of the component, we are destructuring from the hook, the modalName
, the modalProps
, and the closeModal
function. All of these are stored within the ModalContext, and are made available to this component via the useModal
hook.
export const CustomModal = () => {
const { closeModal, modalName, modalProps } = useModal();
const renderModal = useCallback(() => {
const Component = MODAL_LIST[modalName];
return (
<Component
{...modalProps}
closeModal={closeModal}
isOpen={!!modalName}
/>
);
}, [closeModal, modalName, modalProps]);
if (!modalName) {
return null;
}
return renderModal();
};
Then we have a function (called renderModal
) that uses the modalName, by passing it as a key to the MODAL_LIST
object, and in turn receiving the React component (i.e. the modal). Once it has the component, it returns it, while spreading the modalProps
onto it, along with the closeModal
function, and coercing modalName
into a boolean which will determine when the modal should be opened. Meaning, if this function is called, it will return (and thereby render) a React component, or more specifically a modal (hence the name renderModal
).
Finally, if we attempt to render this component without a modalName
, it simply returns null. Otherwise, it calls the function, and the function returns the rendered component, thus rendering the modal.
How do we use this interface in our components?
That's all well and good, but how do we use this? What does using this interface look like for the individual components? Well, there are 2 options:
Option A: Using the default modal
If we just want to use the default modal (i.e. we don't want to have to build a new modal), we can do the below. We don't need to pass a name because the openModal
function has a default argument that sets the modalName
to the default modal. We pass only the props that we want to be passed to the modal.
export const MyComponent = () => {
const { openModal, closeModal } = useModal();
const onPress = () => {
openModal({
props: {
title: 'Do you exist?',
description: "Please confirm your existence",
primaryCTA: {
backgroundColors: [Colors.myColorOne, Colors.myColorTwo],
buttonTitle: 'Yes, I do',
colors: Colors.black,
onPress: () => {
console.log("You clicked the primary 'call to action' button")
}
},
secondaryCTA: {
backgroundColors: [Colors.myColorThree, Colors.myColorFour],
buttonTitle: "No, I don't",
colors: Colors.black,
onPress: closeModal,
},
},
});
}
return <View>{/** Component with some button that has the onPress prop **/}</View>
Option B: Using a custom modal:
If we don't want to use the default modal (i.e. we need to build a fully custom modal), we can use the openModal
function and pass the name of the modal, and the props we want to be spread onto it.
export const MyComponent = () => {
const { openModal } = useModal();
const onPress = () => {
openModal({
modalName: FUN_MODAL,
modalProps: { onDelete: deleteActionFunction },
});
};
return <View>{/** Component with some button that has the onPress prop **/}</View>
What are the Tradeoffs or Things to consider?
Without types, the props passed to the modal are quite looseā-āThere's no type safety on the
modalProps
. This means, almost anything can be added to that object. That's not necessarily the worst thing (it can maybe even be argued that it's a good thing). But on top of that, we're spreading the props, which has its own detriments. One of them is that you're not 100% sure what's getting spread unless you investigate. If we were implementing this in TypeScript, I'd recommend using some form of generic so that the props object being passed to the hook is a bit clearer and more restrictive. Regardless, of whether or not you love/hate this "object freedom", it's worth keeping in mind.We are using stringsā-āThis might be a bit of a nitpick, but for this use case, I'm not a big fan of using strings. In general string literals can easily be misspelled, accidentally changed, etc. Here, we alleviate some of this worry by using a const that holds the string literal. This allows us to rely on the import statement, as well as IntelliSense when we want to pass a
modalName
. We are also using theALERT_LIST
object's built-in key-value syntax to actually find the component. However, we do have to type the name of the key twice (i.e. once as a standalone const, and then again as the key in the object) but that's a price we were willing to pay.-
We call it
modalProps
andmodalName
when we maybe could just call itmodal
andprops
, but we felt likemodal
sounded a bit like a standalone component, andprops
a tad too vague. Sure, the context of the names should help shed light on their meaning, and this is certainly more of a preference but we wanted a little bit more verbosity.- Speaking of names,
CustomModal
might be better namedModalViewer
orModalViewManager
, since it's not literally a modal, it only returns a modal. I'm (always) open to hearing better names and why they're better. But this was a bit of a nitpick for us as we had quite a significant amount of refactoring left. So we stuck withCustomModal
as our name and moved on.
- Speaking of names,
What happens when a string is passed that isn't represented by a componentā-āThis is a valid point and something we'll likely fix soon. We could throw an error when a matching component isn't found. In TypeScript, maybe some form of union of string literals could be helpful to use to restrict rogue strings from getting passed in. I'm not sure if an enum would make the most sense, but I'd be open to hearing why it might. Overall this is something to think about. And I might add, it could be a bit of a result of #2 (that using strings, in this manner, has tradeoffs).
Modal-in-Modalsā-āI'm not sure this implementation can clearly and easily support opening a modal on top of another modal. But to be fair, supporting this ability was a bonus, not a requirement. It does technically give us a little ammo to push back on any modal-in-modal designsĀ š But since this wasn't a requirement for us, we're okay with not being able to support this.
Animationā-āThis approach sort of precludes us from using the native Modal features/props that have to deal with opening and closing the modal. The rendering of the modal is fully controlled by the coercion of the
modalName
to a boolean. Once it's false, the modal is no longer rendered, regardless of the animation props on it. We could make use of React Native's Animated API to resolve this. But it's not the option built specifically for modals, and that made us a little sad. But animation was a bonus, not a requirement, so we're a little less sad.It takes a second to see what's happeningā-āWe think this implementation is simple and clear. But that's a bit subjective. It's totally valid that coming into this, for the very first time, could be slightly jarring. That's part of why we opted for some of the naming verbosity. We thought it might help make the behavior clear.
I think it's worth noting that this list of Tradeoffs and Things to Consider is not exhaustive. As with everything (especially in software design), there are many tradeoffs. These are only the first few that kind of "poked us in the side". In the end, we deemed them acceptable in the short term. Some we'll get around to fixing soon (i.e. error handling, and animations), and others we'll live with. Are there more tradeoffs? Yes, most likely, but perfect doesn't exist, and for now (and for us) this implementation is good.
Conclusion
This implementation is very simple (at least it's intended to be). The file lengths are small, the names are verbose, and it can be quickly read, and improved by engineers at (just about) all levels. That's intentional. As much as we (currently) like this, this implementation is not meant to be used forever (at least for us). It's a placeholder structure that's simple and would be relatively easy for us to re-work the inner implementation details without having to destroy the components using the interface. We know in the future that:
- We will probably want some form of Context that manages our modals
- We will probably want a hook that provides an interface to the Component for the context
- We will probably want to pass props that will be received by the rendered modal
- We will probably want a default component rendered if we don't pass one, and a custom component rendered if we do
This is an option, there are many more with different tradeoffs to consider. But the above implementation gets all four of those items relatively well. Hopefully reading about this implementation helps you in your own modal management scheming, and/or inspires you to modify (this) and write about it for others to learn, discuss, and share.
Posted on May 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.