Confirmation dialog with React, Redux, Thunk & Portals

Matti Bar-Zeev - Feb 22 '20 - - Dev Community

In this one I will share with you the solution I came up with for implementing a confirmation dialog in a React application, using React Portals and Redux.

Disclaimer: There might be other, perhaps better solutions out there. Aside from providing a solution this post describes my thinking and work process on the challenge, which helped me learn more about some key aspects of React development.


Almost any application requires a confirmation dialog. These sort of dialogs are the ones which ask the user whether to proceed with an action or not, prompting a question such as "Do you want to delete this item?" and displaying means to confirm or decline the pending action.

I was looking for some solution for a generic confirmation dialog (or any dialog for that matter) with a clear idea of what I wanted it to support -

  • The dialog modal will use React Portals (https://reactjs.org/docs/portals.html), since it seems to be the most fitting approach for Modals in React.
  • The dialog should be agnostic to the confirmation it handles, and can be reused throughout the application.
  • Displaying the dialog would be determined by the application state, so that it would be possible to append a state snapshot to the app and have the dialog appear and work as expected.
  • The dialog modal itself will not "know" of the app's business logic or its state.

I didn't want to go for a 3rd party solution, since I assumed that this should not be too complex to implement myself, but after some searching I did not come up with any holistic example to what I wanted.
I decided then to take the time and attempt on composing a solution myself, and if all works as planned - share it with you :)

The concept

A confirmation is a state of your application. You will have a single pending confirmation at a time, and I think that it's safe to say that if your app has more than a single pending confirmation at a time - you're doing something wrong UX-wise.

The state

So first of all let's set the state for our confirmation modal. I called it pendingConfirmation and it can have 2 values - null or an object.
when the state is null all is good and we don't have any pending confirmation, but if the state has an object as a value, the confirmation dialog appears.
How does the pendingConfirmation object looks like? it has 2 fields:

  • pendingConfirmationAction - The action which is pending the user's confirmation
  • msg - The message to display for the user So it will look like this:
{
    pendingConfirmationAction: <Redux action object>,
    msg: 'Are you sure you wanna delete this item?',
};
Enter fullscreen mode Exit fullscreen mode

The state reducer

Now that we know how the state looks, let's create the reducer for it.

const pendingConfirmationReducer = (state = null, action) => {
    switch (action.type) {
        case 'REQUEST_CONFIRMATION':
            const {pendingConfirmationAction, msg} = action;
            return {
                pendingConfirmationAction,
                msg,
            };
        case 'CANCEL_CONFIRMATION':
        case 'CONFIRM':
            return null;
        default:
            return state;
    }
};

export default pendingConfirmationReducer;
Enter fullscreen mode Exit fullscreen mode

As you can see, we have 3 action types we're handling here:

  • REQUEST_CONFIRMATION - When we ask for a confirmation
  • CANCEL_CONFIRMATION - When we want to cancel the confirmation
  • CONFIRM - When we want to mark the confirmation as... confirmed (Yes, you can/should convert the types into constants, it's better, you're right)

The action creators

What triggers this reducer are actions, and here is the action creators we're using when we wish to pop up a confirmation dialog, cancel or confirm it -

export const createConfirmAction = (pendingConfirmationAction, msg) => {
    return {
        type: 'REQUEST_CONFIRMATION',
        pendingConfirmationAction,
        msg,
    };
};

export const cancelConfirmation = () => {
    return {
        type: 'CANCEL_CONFIRMATION',
    };
};

export const confirmPendingAction = () => {
    return (dispatch, getState) => {
        const cancelConfirmationAction = cancelConfirmation();
        if (getState().pendingConfirmation) {
            const pendingConfirmAction = getState().pendingConfirmation.pendingConfirmationAction;
            dispatch(pendingConfirmAction);
            dispatch(cancelConfirmationAction);
        } else {
            dispatch(cancelConfirmationAction);
        }
    };
};
Enter fullscreen mode Exit fullscreen mode

Wow there, what is that confirmPendingAction action create there? well, my friends, this is a thunk...

The Thunk

Quoting from redux-thunk repo, a Thunk

allows you to write action creators that return a function instead of an action. The thunk can be used to delay the dispatch of an action, or to dispatch only if a certain condition is met.

So here we're checking if there is a pending confirmation, and if there is we simply dispatch its pending action.
Remember? there can be only a single pending confirmation action at a time :)
After that we simply "cancel" the confirmation to remove it (maybe "hide" is a better name, you decide).

Why Portals?

The basic understanding is that a dialog is made out of 2 aspects -

  • The generic infra which displays the dialog with some content
  • The content of the dialog, be it confirmation, notifications etc.

The infra for displaying the content should be agnostic to the application using it. Using React Portal compliments this approach, separating the rendering of the dialogs from the application root element.
You can consider it as a sort of an application decorator.
The nice thing about portals is that although they are not under the application, they can still communicate via events with it. So if a component within a portal has a click event, we can listen to it on the application and act accordingly.

The Backdrop & Modal

Well this is all great but where is the freaking modal?
So our modal is made of 2 things - backdrop and modal.
The backdrop is what we put behind the modal to prevent any unwanted mouse interaction with the background. The modal is the div we're displaying in the middle of the screen which presents our confirmation question and buttons.

First we add the index.html 2 more divs right after our application 'root' div, one for the backdrop and one for the modal (make sure that the backdrop comes before the modal) -

<div id="root"></div>
<div id="backdrop"></div>
<div id="modal"></div>
Enter fullscreen mode Exit fullscreen mode

Now let's create the Backdrop component, which is very simple -

import React from 'react';
import {createPortal} from 'react-dom';

const Backdrop = () => {
    const backdropRoot = document.getElementById('backdrop');
    return createPortal(<div className="backdrop" />, backdropRoot);
};

export default Backdrop;

Enter fullscreen mode Exit fullscreen mode

Here is an example for its style -

.backdrop {
    backdrop-filter: blur(2px);
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
}
Enter fullscreen mode Exit fullscreen mode

When we render this component it will attach itself to the <div id="backdrop"></div>

And now that we got the Backdrop component, let's create the Modal component, which is not that different from the Backdrop component but obviously we won't want to mix the two -

import React from 'react';
import {createPortal} from 'react-dom';

const Modal = ({children}) => {
    const modalRoot = document.getElementById('modal');
    return createPortal(<div className="modal">{children}</div>, modalRoot);
};

export default Modal;

Enter fullscreen mode Exit fullscreen mode

Use the .modal CSS class in order to position your modal wherever you see fit, here is an example:

.modal {
    background: #fff;
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    margin: auto;
    width: 400px;
    height: 150px;
    box-shadow: 0 5px 10px 2px rgba(195, 192, 192, 0.5);
    padding: 20px;
    text-align: center;
    border-radius: 6px;
}
Enter fullscreen mode Exit fullscreen mode

In order to keep things in order and the DRY concept, I've created a ConfirmationModal component which is a specific implementation of the Modal component, and this is the one I will use later on.

Rendering the Confirmation modal

We have all the ingredients ready, only thing left to do is to render them on demand. Our application main JS file is the one which is responsible on rendering the confirmation modal. It is aware of the pendingConfirmation state, and when it has value, it renders the Backdrop and the ConfirmationModal.

import {useSelector, useDispatch} from 'react-redux';

...

const App = () => {

    ...

    const dispatch = useDispatch();
    const pendingConfirmation = useSelector(state => state.pendingConfirmation);

    ...

    function onCancelConfirmation() {
        dispatch(cancelConfirmation());
    }

    function onConfirmPendingAction() {
        dispatch(confirmPendingAction());
    }

    ...

    return (
        <div className="App">
            {pendingConfirmation && <Backdrop />}
            {pendingConfirmation && (
                <ConfirmationModal onConfirm={onConfirmPendingAction} onCancel={onCancelConfirmation}>
                    {pendingConfirmation.msg}
                </ConfirmationModal>
            )}
        </div>
    );
};

Enter fullscreen mode Exit fullscreen mode

Popping up a confirmation dialog

At last, when we wish to pop up a confirmation dialog, we use the corresponding action creator, like so -

const confirmDeleteItemAction = createConfirmAction(
    <pending action creator>,
    'Are you sure you wanna delete this item?'
);
dispatch(confirmResetGameAction);

Enter fullscreen mode Exit fullscreen mode

... and so

That's that :)
I hope it helped you and please let me know (down in the comments below) if you have any thoughts, feedback or questions about the ideas presented here.

Cheers!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .