Abstracting Our Redux Confirmation Modal Pattern

After writing DeleteSomethingModalOpened and DeleteSomethingModalClosed for the 34287th time, we’d finally had enough. Global Redux actions and reducers were supposed to save us from duplicating common patterns like this, but thanks to new confirmation modals just copying the last confirmation modal, we’d found our store littered with confirmation-modal specific actions. If you’ve found yourself sliding down a similar hill, then this post is for you!

Modals are a broad topic, but here we’ll only talk about the confirmation modal. Applications frequently use the confirmation modal in instances where the user is required to take one additional action. Did someone really mean to delete that super important object in an application? Not always. We’ll aim for some kind of very explicit “are you sure?” experience.

Enter the first Redux Sagas pattern that really, really felt like it was helping to simplify a difficult problem in our application. There are other ways of accomplishing something similar, but this is a really great way of showcasing the power of the synchronous feel of these generators. Here’s a common example from our codebase that shows the pattern where we want to wait for a confirmation step before continuing on with the final action.

function *deleteAsset (action: Action<'AssetDeleteRequested'>) {
  const { payload: { id }, meta: { callbackAction } } = action;

  yield put({ type: 'ConfirmAction', payload: { actionType: action.type } });
  const { canceled } = yield race({
    confirmed: take('ActionConfirmed'),
    canceled: take('ActionCanceled'),
  });

  if (canceled) {
    yield cancel();
  }

  try {
    yield call(Api.Assets.del, id);
    yield put({
      type: 'AssetDeleted',
      payload: { id },
    });

    yield put(callbackAction);
  } catch (error) {
    yield put({ type: 'AssetDeleteFailed' });
  }
}

An important note in this one is the callbackAction in the meta key (our actions largely follow the Flux Standard Action standard). You may have guessed, but this is intended to be the Redux action to fire if the action is confirmed. For illustration purposes, this action payload might look something like this:

{
  payload: { id: 'abcd1234' },
  meta: {
    callbackAction: {
      type: 'RedirectToHome',
      payload: { message: 'wow that thing totally worked' }
    }
  }
}

The first thing the saga does is put a ConfirmAction action. In our application there’s a connected ActionModal component, so the associated reducers will listen for that action and update state to show the modal. That connected component also has just two action creators:

// containers/ActionModal.ts
const mapDispatchToProps = dispatch => ({
  onCancel: () => dispatch({ type: 'ActionCanceled' }),
  onSubmit: () => dispatch({ type: 'ActionConfirmed' }),
});

Now that ConfirmAction has been fired, the saga has a race for which of these two actions will come back first from the modal. If it’s cancel, our reducers will handle that cancel action and we just cancel the saga. If it’s confirmed, however, we merrily go on our way to do the destructive action we set out to do in the first place. If that destructive action succeeds, we put that callbackAction from the meta key and we’re done!

Can we see more of the ActionModal?

While we don’t necessarily mind showing it off, that’s the most implementation-specific piece of this pattern. Some implementations might go the route of Portals or something completely different, but a nice aspect of this approach is that it doesn’t dictate how the UI is built.

For example, we implemented this by pulling that connected ActionModal component into any route that needs it. That way we can pass in props for things like messages and button text without needing to litter our Redux store with UI-specific details.

Hope that helps! Feel free to tell me I’m wrong, I’d love to hear how other folks are solving this problem.✌️