Problem: Modals & the ceremony around managing them
I love that react allows us to write declarative user interfaces using JavaScript and JSX. However, when it comes to modals, I dislike how they are usually implemented in react - as hidden markup alongside main content made visible or hidden in response to state changes. This forces us to split our logic flow to different methods, and adds extra ceremony if we need to share any data between them.
To understand what I mean, look at the below fictitious example:
const [isVisible, setIsVisible] = useState(false);const [order, setOrder] = useState(null);const [customer, setCustomer] = useState(null);const deleteOrder = async (orderId) => {// 1: Data fetched/computed before showing the modalconst order = await fetchOrder(orderId);const customer = await fetchCustomer(order.customerId);const canDeleteOrder = await isDeleteAllowed(customer);if (!canDeleteOrder) {return;}// 2: Set the data in state so that modal can use it andsetOrder(order);setCustomer(customer);// 3: Show the modalsetIsVisible(true);};const onDeleteConfirm = (dataFromModal) => {// 4: Handle modal confirmation, process with data captured// from the modal and data fetched before showing the modaldoSomethingWith(dataFromModal, order, customer);// 5: Hide modalsetIsVisible(false);// 6: reset the state variables, because they were temporarysetOrder(null);setCustomer(null);};const hideModal = () => {// 7: User cancelled, we need to cleanup and hide the modal.setIsVisible(false);setOrder(null);setCustomer(null);};return (<View>....<ButtononPress={() => deleteOrder("123")}title="Delete Order" /><Modal visible={isModalVisible}><Text>{/* Display order and customer details */}</Text><TextInput>{/* capture data here */}</TextInput><ButtononPress={() => onDeleteConfirm(capturedData)}title="Confirm Delete"/></Modal>....</View>);
There is a lot going on here!
To start with, we have three methods, one for executing some logic before opening the modal, one to continue executing some logic after users confirms on the modal, and one to cleanup and hide the modal if the user cancelled.
There are multiple state variables for storing current order and customer details. These are temporarily stored during the lifetime of the modal, just so that we can share some data between the two methods and the modal.
Then there is the visibility state variable for the modal itself.
Now, I am OK with this code even with all the boilerplate and ceremony; as long as there are no other modals on the same page. But if we introduce one more, it doubles the complexity!! Add one more, it becomes a maintainability nightmare.
Solution: Awaitable modals
The solution is what I call 'Awaitable Modals'. Or more appropriately, modals with an imperative API. In general, UI should be declarative and usage of imperative APIs are not advised. However, I believe modals, alerts and toasts are good examples where an imperative API is so much better and makes sense. It's easier to imagine them as temporary UI elements that show up in response to user actions, instead of something that represents the application state.
Here's how it would look like:
const { openModal, renderModal } = useAwaitableModal((modal, params) => (<View><Text>{/* Display content from params or any state variable */}{params}</Text><TextInput>{/* Capture some data*/}</TextInput><ButtononPress={() => modal.closeWithResult(capturedData)}title="Submit"/><ButtononPress={() => modal.closeWithError("Error Message")}title="Cancel"/></View>));const deleteOrder = async (orderId) => {const order = await fetchOrderDetails(orderId);const customer = await fetchCustomerDetails(order.customerId);const canDeleteOrder = await isDeleteAllowed(customer);if (!canDeleteOrder) {return;}try {const dataFromModal = await openModal({/* Pass parameters to the modal */});await doSomethingWith(dataFromModal, order, customer);} catch (error) {// User cancelled the modal// Do anything you want here}};return (<>{renderModal()}<Button onPress={deleteOrder} title="Show Modal" /></>);
This is so much simpler! Let's list down the differences.
- To begin with - we have no state variables now! Not even for controlling the modal. We just call openModal whenever we want to show the modal.
- There is only one method for handling the modal logic, instead of three in the previous example!! Also we can await for the modal to be closed!
- Since there is only one method, we can share data between the pre-open, success and cancelled scenarios just by using the variables in scope. Data fetched/computed before opening the modal is still available after closing it. No need of cleaning up temporary state variables.
- We can pass parameters to the modal! The modal can receive data directly via openModal method. No need to setup state variables for this too, in contrast to the previous example.
Modelling the modal API like this has made it easier for me to simplify the complexity around them in more than a few projects. I have been doing it like this since the Angular JS days of my early career, and I find it very useful.
Implementation
Let's dive into how we can implement this API in React Native. Even though I am using React Native here, same pattern can be used with React web as well. Just change all View elements to div's or semantically appropriate elements, and use your favourite modal library for the Modal.
First let's make a hook called useAwaitableModal that takes a render function as parameter. It should return an object with renderModal and openModal properties. We need a state variable as well here for controlling the visibility of the modal.
export const useAwaitableModal = (render) => {const [isVisible, setIsVisible] = useState(false);const renderModal = () => {return <Modal visible={isVisible}>{render()}</Modal>;};return () => ({openModal: () => setIsVisible(true),renderModal,});};
If you notice in the previous example, render takes two arguments: modal as the API object, and params for taking in additional parameters when opening the modal. Let's make the change.
export const useAwaitableModal = (render) => {const [isVisible, setIsVisible] = useState(false);const modalAPI = {closeWithResult: (result) => {setIsVisible(false);return Promise.resolve(result);},closeWithError: (error) => {setIsVisible(false);return Promise.reject(error);},};const renderModal = () => {return (<Modal visible={isVisible}>{render(modalAPI, params)} // 1. WHERE DO params COME FROM??</Modal>);};const openModal = (params) => {// 2. We Recieve params here.// Need to make it available for rendering.// 3. This should return a promise.// The promise should be resolved/rejected when we call modal.closereturn new Promise((resolve, reject) => {setIsVisible(true);});};return () => ({openModal,renderModal,});};
I left a few comments in the code. These are the pending dots that need to be connected to make it work. A ref variable works well here, which would hold all these values and methods for us.
Here's how it the finished hook would look:
export const useAwaitableModal = (render) => {const [isVisible, setIsVisible] = useState(false);const promiseRef = useRef({resolve: () => {},reject: () => {},params: {},});const renderModalWithParamsAndContext = () => {const closeWithError = (error) => {promiseRef.current.reject(error);setIsVisible(false);};const closeWithResult = (result) => {promiseRef.current.resolve(result);setIsVisible(false);};const modalAPI = {isVisible,closeWithError,closeWithResult,};return (<Modal visible={isVisible} style={{ flex: 1 }} transparent={true}>{render(modalAPI, promiseRef.current.params)}</Modal>);};const openModal = (params) => {return new Promise((resolve, reject) => {promiseRef.current = {resolve,reject,params,};setIsVisible(true);});};return {openModal,renderModal: renderModalWithParamsAndContext,};};
We can use it as shown in the example under Solution: Awaitable modals
Demo
Here's a demo of the hook in action. The code and sample usage are available at: sarathkcm/react-native-awaitable-modals
Remarks
I wanted to acknowledge that this is not something new and if you searched google for an "imperative modal in react" you will find a lot of articles with various implementations. Some of the implementations actually closely resemble what I had implemented before in my projects, using a context and a global placeholder for rendering the modals. The general idea of an imperative modal itself is not new regardless of the framework, and there are many ways to achieve the same result.
The method mentioned in this article is something I came up only for the article. I like that it's isolated and scoped only to the parent page/component. However, since it's not something I use in production (yet), if you wanted to use it in your project, please make sure to test it thoroughly.