Create a root-level modal store in React Native

lfkwtz

Michael Lefkowitz

Posted on January 8, 2020

Create a root-level modal store in React Native

Want to stay up-to-date? Check out React Native Now, the bi-weekly React Native newsletter


In the original design and flows of our React Native apps, we utilized the native Alert for a few different needs - but primarily they were error, confirmation, and success notifications.

These worked well in most cases for the past year for most every case we had ever needed them - the only issue we ever had was a lack of styling flexibility, so items in a list for instance would end up center aligned on iOS, which was less than ideal.

Success notifications were used most often - the alerts would usually appear following a successful API response after filling out a form, which historically had been built as single screens. When these success notifications were triggered, we would also kick our navigation back a screen or two so our users would be where we wanted them.

The problem

As our design team begun to share mocks of screens with us that had a fresh coat of paint on them, they also had a major change in many of our original flows. The idea is to move away from scrolling, single screen forms to a more mobile-friendly, multi screen flow. In addition, these new designs replaced our native alerts with full screens that was more consistent with our branding and enabled us to style them with greater flexibility.

While our engineering team liked the look of these new flows and success screens, replacing our native alerts with them proved to be more work than we expected - by making the alert into a separate screen, we now lost the ability to trigger a navigation "behind the scenes" while the user was interacting with our alert. And, since we were also moving from single screen to multi screen forms, we couldn't simply replace the current screen in the stack with a success screen.

The solution

Because of this "behind the scenes" magic we were introducing, we wanted to avoid having the modal as part of our navigation stack completely. To handle this, we created a modal store within Redux and a Modal component at the root of our app - which we'll walk through the basics of how to create in this tutorial. This same pattern could be applied to your choice of state management solution if Redux doesn't work for you.

To start, we'll initialize a new React Native project. Then, add React Navigation with a couple dummy screens and Redux. In our Redux store, we'll setup a modal reducer that has an id field. I used the React Native CLI to start, but you may be able to simplify things by utilizing Expo. To see what our project looks like so far, check it out here.

Great, now that we have a baseline project setup, let's discuss the main steps involved in building this out:

  1. Create a connected-RootModal component at the root of our App
  2. Create unique views within the parent Modal
  3. Let the store drive the visibility of the RootModal (and specific view)
  4. Add support for props

Alright, let's get started.

1. Create a connected-RootModal component at the root of our App

First step, we'll create a file called RootModal.js that will look like this:

import * as React from 'react';
import {Modal, Button, View} from 'react-native';
import {connect} from 'react-redux';

export class RootModal extends React.Component {
  render() {
    return (
      <Modal visible={true} animationType="fade" testID="modal">
        <View
          style={{
            flex: 1,
            padding: 20,
            justifyContent: 'space-between',
          }}>
          <View />
          <Button onPress={() => {}} title="Close" color="blue" />
        </View>
      </Modal>
    );
  }
}

const mapStateToProps = state => {
  return {
    id: state.modal.id,
  };
};

export default connect(mapStateToProps)(RootModal);
Enter fullscreen mode Exit fullscreen mode

Here, we've created a very simple connected-component that displays a native Modal with a button at the bottom.

Then, we will import it into our App.js and drop it in.

import RootModal from './src/modals/RootModal';

export default class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <Navigation />
        <RootModal />
      </Provider>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now when we refresh our app, we should see something like this:

Step One

Exactly what we want, a root-level Modal that covers up the rest of our app. Click here to see what our project currently looks like.

2. Create unique views within the parent Modal

Next, we're going to create some unique views / components that the root modal can display. For simplicity sake in this tutorial, we aren't going to make anything too fancy - but it should be enough to spark your understanding of the concept.

Let's make two new files - Success.js and Error.js

import * as React from 'react';
import {Text, View} from 'react-native';

export default class Success extends React.Component {
  render() {
    return (
      <View
        style={{
          flex: 1,
          justifyContent: 'space-between',
        }}>
        <View />
        <Text style={{fontSize: 20, textAlign: 'center'}}>
          Congratulations. The thing you wanted to happen has happened.
        </Text>
        <View />
      </View>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
import * as React from 'react';
import {Text, View} from 'react-native';

export default class Error extends React.Component {
  render() {
    return (
      <View
        style={{
          flex: 1,
          justifyContent: 'space-between',
        }}>
        <View />
        <Text style={{fontSize: 25, color: 'red', textAlign: 'center'}}>
          There has been an error.
        </Text>
        <View />
      </View>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we want to import them into our RootModal file and test out displaying them - so we'll replace that placeholder empty View we had.

import Error from './Error';
import Success from './Success';

const Modals = {
  Error: Error,
  Success: Success,
};

export class RootModal extends React.Component {
  render() {
    return (
      <Modal visible={true} animationType="fade" testID="modal">
        <View
          style={{
            flex: 1,
            padding: 20,
            justifyContent: 'space-between',
          }}>
          <Modals.Success />
          <Button onPress={() => {}} title="Close" color="blue" />
        </View>
      </Modal>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's take a look and see what this looks like now when using Modals.Success and Modals.Error
SuccessError

Perfect. Click here to see what our project currently looks like.

3. Let the store drive the visibility of the RootModal

Now for the fun part. What we want to do setup our store to set and unset a specific id. Our RootModal component will then look at that id and then determine which modal to display. If the id is empty, no modal will be shown.

First, we'll create a ModalActions.js and create two actions - one that will set an id and another that will clear it out.

const showModal = ({id}) => {
  return dispatch => {
    dispatch({
      type: 'MODAL__SET_ID',
      payload: id,
    });
  };
};

const hideModal = () => {
  return dispatch => {
    dispatch({
      type: 'MODAL__SET_ID',
      payload: '',
    });
  };
};

export const ModalActions = {
  showModal,
  hideModal,
};
Enter fullscreen mode Exit fullscreen mode

Then, we'll update our ModalReducer to support these actions:

export const id = createReducer(ID_INITIAL_STATE, {
  ['MODAL__SET_ID'](state, {payload}) {
    return payload;
  },
});
Enter fullscreen mode Exit fullscreen mode

Next, we need to update our RootModal. First we need to add mapDispatchToProps to our Redux-connection and import our new hideModal action. Then, we'll create a constant that assigns to a component based on the id in our store and update the native Modal component to be displayed only if that id is truthy. And finally, inject the custom view into the Modal. All together, it should look like this:

import * as React from 'react';
import {Modal, Button, View} from 'react-native';
import {connect} from 'react-redux';
import Error from './Error';
import Success from './Success';

// import our new actions
import {ModalActions} from '../store/modules/Modal/ModalActions';

const Modals = {
  Error: Error,
  Success: Success,
};

export class RootModal extends React.Component {
  render() {
    const {id, hideModal} = this.props;

    // assign a constant that is either one of our custom views or a noop function if the id is not set
    const ModalView = Modals[id] || function() {};

    return (
      // show the Modal if the id is set to a truthy value
      <Modal visible={Boolean(id)} animationType="fade" testID="modal">
        <View
          style={{
            flex: 1,
            padding: 20,
            justifyContent: 'space-between',
          }}>
          {/* inject the custom view */}
          <ModalView />
          <Button onPress={hideModal} title="Close" color="blue" />
        </View>
      </Modal>
    );
  }
}

const mapStateToProps = state => {
  return {
    id: state.modal.id,
  };
};

// add hideModal action to props
const mapDispatchToProps = {
  hideModal: ModalActions.hideModal,
};

const ConnectedRootModal = connect(
  mapStateToProps,
  mapDispatchToProps,
)(RootModal);

export default ConnectedRootModal;
Enter fullscreen mode Exit fullscreen mode

Lastly, we'll update one of our screens to support our new showModal action and add a couple buttons to support displaying our RootModal.

<Button
  title="Open Success Modal"
  onPress={() => {
    this.props.showModal({id: 'Success'});
  }}
/>
<Button
  title="Open Error Modal"
  onPress={() => {
    this.props.showModal({id: 'Error'});
  }}
/>
Enter fullscreen mode Exit fullscreen mode

All wrapped up - here's what this looks like (and here's our project at its current stage):

Both Working

4. Add support for props

How can we make this pattern a little more flexible? For one, we should setup an additional reducer that takes in some custom props that will be passed through to our custom views. All we have to is create the action and reducer to support it and spread those props in on our RootModal.

To handle this, we'll first update our showModal and hideModal actions

// add to showModal
dispatch({
  type: 'MODAL__SET_MODAL_PROPS',
  payload: modalProps,
});

// add to hideModal
dispatch({
  type: 'MODAL__SET_MODAL_PROPS',
  payload: {},
});
Enter fullscreen mode Exit fullscreen mode

Then, we'll add a modalProps reducer to our ModalReducers file

const MODAL_PROPS_INITIAL_STATE = {};

export const modalProps = createReducer(MODAL_PROPS_INITIAL_STATE, {
  ['MODAL__SET_MODAL_PROPS'](state, {payload}) {
    return payload;
  },
});

export const ModalReducer = combineReducers({
  id,
  modalProps,
});
Enter fullscreen mode Exit fullscreen mode

Next, we'll modalProps to our mapStateToProps in the RootModal file and spread those props in:

<ModalView {...this.props.modalProps} />
Enter fullscreen mode Exit fullscreen mode

And finally, when we are calling our showModal action, we'll be able to pass along any additional props that may be relevant, and update those unique views to support them.

// HomeScreen.js
<Button
  title="Open Error Modal"
  onPress={() => {
    this.props.showModal({
      id: 'Error',
      modalProps: {errorMessage: this.state.errorMessage},
    });
  }}
/>

// Error.js
<Text style={{fontSize: 20, color: 'green', textAlign: 'center'}}>
  {this.props.errorMessage}
</Text>
Enter fullscreen mode Exit fullscreen mode

Our project now looks like this, and here's a preview of how everything looks all wrapped up:

With Custom Props

Final thoughts

Hopefully this tutorial has given you enough context to help use this concept as a starting point for however you may need to utilize a custom store-backed root-level modal. In our apps, our RootModal is a little more "dumb" than this example - and we leave all actions, including closing, to the Redux-connected inner views. Also, we eventually ran into an issue related to this long-standing bug when building out this pattern - which led us to replace our use of a native Modal with the react-native-root-modal library. Hopefully you don't need that extra step, but it is worth mentioning.

Sample

💖 💪 🙅 🚩
lfkwtz
Michael Lefkowitz

Posted on January 8, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related