carlesba carlesba - 2 months ago 17
Javascript Question

How can I display a modal dialog in Redux that performs asynchronous actions?

I'm building an app that needs to show a confirm dialog in some situations.

Let's say I want to remove something, then I'll dispatch an action like

deleteSomething(id)
so some reducer will catch that event and will fill the dialog reducer in order to show it.

My doubt comes when this dialog submits.


  • How can this component dispatch the proper action according to the first action dispatched?

  • Should the action creator handle this logic?

  • Can we add actions inside the reducer?



edit:

to make it clearer:

deleteThingA(id) => show dialog with Questions => deleteThingARemotely(id)

createThingB(id) => Show dialog with Questions => createThingBRemotely(id)


So I'm trying to reuse the dialog component. Showing/hiding the dialog it's not the problem as this can be easily done in the reducer. What I'm trying to specify is how to dispatch the action from the right side according to the action that starts the flow in the left side.

Answer

The approach I suggest is a bit verbose but I found it to scale pretty well to complex apps. When you want to show a modal, fire an action describing which modal you want to see:

Dispatching an Action to Show the Modal

this.props.dispatch({
  type: 'SHOW_MODAL',
  modalType: 'DELETE_POST',
  modalProps: {
    postId: 42
  }
})

(Strings can be constants of course; I’m using inline strings for simplicity.)

Writing a Reducer to Manage Modal State

Then, make sure you have a reducer that just accepts these values:

const initialState = {
  modalType: null,
  modalProps: {}
}

function modal(state = initialState, action) {
  switch (action.type) {
    case 'SHOW_MODAL':
      return {
        modalType: action.modalType,
        modalProps: action.modalProps
      }
    case 'HIDE_MODAL':
      return initialState
    default:
      return state
  }
}

/* .... */

const rootReducer = combineReducers({
  modal,
  /* other reducers */
})

Great! Now, when you dispatch an action, state.modal will update to include the information about the currently visible modal window.

Writing the Root Modal Component

At the root of your component hierarchy, add a <ModalRoot> component that is connected to the Redux store. It will listen to state.modal and display an appropriate modal component, forwarding the props from the state.modal.modalProps.

// These are regular React components we will write soon
import DeletePostModal from './DeletePostModal'
import ConfirmLogoutModal from './ConfirmLogoutModal'

const MODAL_COMPONENTS = {
  'DELETE_POST': DeletePostModal,
  'CONFIRM_LOGOUT': ConfirmLogoutModal,
  /* other modals */
}

const ModalRoot = ({ modalType, modalProps }) => {
  if (!modalType) {
    return <span /> // after React v15 you can return null here
  }

  const SpecificModal = MODAL_COMPONENTS[modalType]
  return <SpecificModal {...modalProps} />
}

export default connect(
  state => state.modal
)(ModalRoot)

What have we done here? ModalRoot reads the current modalType and modalProps from state.modal to which it is connected, and renders a corresponding component such as DeletePostModal or ConfirmLogoutModal. Every modal is a component!

Writing Specific Modal Components

There are no general rules here. They are just React components that can dispatch actions, read something from the store state, and just happen to be modals.

For example, DeletePostModal might look like:

import { deletePost, hideModal } from '../actions'

const DeletePostModal = ({ post, dispatch }) => (
  <div>
    <p>Delete post {post.name}?</p>
    <button onClick={() => {
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    }}>
      Yes
    </button>
    <button onClick={() => dispatch(hideModal())}>
      Nope
    </button>
  </div>
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

The DeletePostModal is connected to the store so it can display the post title and works like any connected component: it can dispatch actions, including hideModal when it is necessary to hide itself.

Extracting a Presentational Component

It would be awkward to copy-paste the same layout logic for every “specific” modal. But you have components, right? So you can extract a presentational <Modal> component that doesn’t know what particular modals do, but handles how they look.

Then, specific modals such as DeletePostModal can use it for rendering:

import { deletePost, hideModal } from '../actions'
import Modal from './Modal'

const DeletePostModal = ({ post, dispatch }) => (
  <Modal
    dangerText={`Delete post ${post.name}?`}
    onDangerClick={() =>
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    })
  />
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

It is up to you to come up with a set of props that <Modal> can accept in your application but I would imagine that you might have several kinds of modals (e.g. info modal, confirmation modal, etc), and several styles for them.

Accessibility and Hiding on Click Outside or Escape Key

The last important part about modals is that generally we want to hide them when the user clicks outside or presses Escape.

Instead of giving you advice on implementing this, I suggest that you just don’t implement it yourself. It is hard to get right considering accessibility.

Instead, I would suggest you to use an accessible off-the-shelf modal component such as react-modal. It is completely customizable, you can put anything you want inside of it, but it handles accessibility correctly so that blind people can still use your modal.

You can even wrap react-modal in your own <Modal> that accepts props specific to your applications and generates child buttons or other content. It’s all just components!

Other Approaches

There is more than one way to do it.

Some people don’t like the verbosity of this approach and prefer to have a <Modal> component that they can render right inside their components with a technique called “portals”. Portals let you render a component inside yours while actually it will render at a predetermined place in the DOM, which is very convenient for modals.

In fact react-modal I linked to earlier already does that internally so technically you don’t even need to render it from the top. I still find it nice to decouple the modal I want to show from the component showing it, but you can also use react-modal directly from your components, and skip most of what I wrote above.

I encourage you to consider both approaches, experiment with them, and pick what you find works best for your app and for your team.