We often need to allow our users to perform various delicate operations. They often can’t be undone, which might have dire consequences. Therefore, the good idea is to prompt the user for confirmation.
Across our application, the above case might happen frequently. Creating a separate confirmation modal for each of them is far from ideal. In this article, we explore various ways to tackle this issue using React, Redux with Redux Toolkit, and TypeScript.
We focus on managing the state, and for the user interface, we use Material-UI.
A simple approach
The most straightforward approach worth noting is storing the callback of the confirm button in the store.
1 2 3 4 |
interface ConfirmationModalState { isOpened: boolean; onConfirm: (() => void) | null; } |
1 2 3 4 5 6 7 8 |
const { open } = useConfirmationModalManagement(); const { deleteCurrentPost } = usePostsManagement(); const handleDeleteButtonClick = () => { open({ onConfirmation: deleteCurrentPost }); } |
While it is possible, it is officially discouraged by the Redux maintainers. The same goes for storing any other non-serializable items, such as promises. Also, one of the goals of Redux Toolkit is to help provide good defaults when working with a Redux store. Therefore, storing non-serializable values in our store results in Redux Toolkits showing warnings in the console. To get rid of them, we can use the serializableCheck property described in the documentation.
You can also read more in this discussion on StackOverflow
If we are fine with experiencing some issues with time-travel debugging and our store not being serializable, we can go with the simple, straightforward approach.
Approach with Thunk
With Redux Thunk, we can dispatch asynchronous actions and wait for them to finish. We can use that to open a confirmation modal and wait for the user to confirm the action.
1 2 3 4 5 6 7 8 9 10 |
const { open } = useConfirmationModalManagement(); const { deleteCurrentPost } = usePostsManagement(); const handleDeleteButtonClick = async () => { const isConfirmed = await open(); if (isConfirmed) { deleteCurrentPost(); } } |
By doing the above, we no longer need to put non-serializable data into the store. Still, we write the logic of the confirmation button in the same place where we open the modal. It looks simple, and it is easy to use.
First, let’s define the state of our modal.
1 2 3 4 5 6 7 8 9 10 11 |
export interface ConfirmationModalState { isOpened: boolean; isConfirmed: boolean; isDeclined: boolean; } export const initialConfirmationModalState: ConfirmationModalState = { isOpened: false, isConfirmed: false, isDeclined: false }; |
Feel free to add more properties, such as the description that the modal contains.
Above, we define the isConfirmed and isDeclined properties. Let’s create a slice that contains both the reducer and the basic actions.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { createSlice } from '@reduxjs/toolkit'; import { initialConfirmationModalState } from './state'; const confirmationModalSlice = createSlice({ name: 'confirmationModal', initialState: initialConfirmationModalState, reducers: { open: state => { state.isOpened = true; state.isDeclined = false; state.isConfirmed = false; }, confirm: state => { state.isConfirmed = true; state.isOpened = false; }, decline: state => { state.isDeclined = true; state.isOpened = false; } } }) export const confirmationModalActions = confirmationModalSlice.actions; export default confirmationModalSlice; |
Redux Toolkitt uses Immer under the hood to allow us to simply mutate the state object. For more information, check out the documentation.
Above, we’ve defined a set of actions that change our state. Now, let’s create an async thunk action where the magic happens.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import { createAsyncThunk } from '@reduxjs/toolkit'; import ThunkExtraArguments from '../../types/ThunkExtraArguments'; import { confirmationModalActions } from './slice'; import { AppState} from '../reducers'; const confirmationModalThunkActions = { open: createAsyncThunk<boolean, void, { extra: ThunkExtraArguments }>( 'confirmationModal', async (_, { extra, dispatch }) => { const store = extra.store; dispatch(confirmationModalActions.open()); return new Promise<boolean>(resolve => { const unsubscribe = store.subscribe(() => { const state: AppState = store.getState(); if (state.confirmationModal.isConfirmed) { unsubscribe(); resolve(true); } if (state.confirmationModal.isDeclined) { unsubscribe(); resolve(false); } }) }); } ) } export default confirmationModalThunkActions; |
At the beginning of the thunk action, we open up our modal with dispatch(confirmationModalActions.open()). We then return a new promise that resolves when the user either confirms the action or declines it. An important part is that we need to unsubscribe to remove the listener.
You can notice that we are using the extra argument in our thunk. This is because, by default, we can’t access the store.subscribe method in our thunk. Thankfully, we can define extra arguments that we want to have access to.
1 2 3 4 5 6 7 |
import { Store } from '@reduxjs/toolkit'; interface ThunkExtraArguments { store: Store; } export default ThunkExtraArguments; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; import ThunkExtraArguments from '../types/ThunkExtraArguments'; import combinedReducers from './reducers'; function createStore() { const thunkArguments = {} as ThunkExtraArguments; const customizedMiddleware = getDefaultMiddleware(({ thunk: { extraArgument: thunkArguments } })); const store = configureStore({ reducer: combinedReducers, middleware: customizedMiddleware, }); thunkArguments.store = store; return store; } export default createStore; |
And that’s it when it comes to configuring our store.
Dispatching our actions
Now, let’s create a hook for managing the state of our confirmation modal.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import { useDispatch, useSelector } from 'react-redux'; import { AppState } from '../../reducers'; import { confirmationModalActions } from '../slice'; import confirmationModalThunkActions from '../thunk'; import { AppDispatch } from '../../../App'; function useConfirmationModalManagement() { const dispatch: AppDispatch = useDispatch(); const { isOpened } = useSelector((state: AppState) => ({ isOpened: state.confirmationModal.isOpened })) const open = async () => { const { payload } = await dispatch(confirmationModalThunkActions.open()); return payload; } const confirm = () => { return dispatch(confirmationModalActions.confirm()); } const decline = () => { return dispatch(confirmationModalActions.decline()); } return { isOpened, open, confirm, decline } } export default useConfirmationModalManagement; |
Please notice that the open function uses confirmationModalThunkActions.
We also need the AppDispatch type as suggested in the documentation. Thanks to it, TypeScript knows that dispatch(confirmationModalThunkActions.open()) returns a promise that resolves with payload containing true, or false.
1 2 3 4 5 6 |
import React from 'react'; import createStore from './store'; const store = createStore(); export type AppDispatch = typeof store.dispatch; |
Since we’ve got everything we need now, let’s define a ConfirmationModal component. In this example, we use Material-UI for the sake of simplicity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import React from 'react'; import Modal from '@material-ui/core/Modal'; import Button from '@material-ui/core/Button'; import Card from '@material-ui/core/Card'; import CardActions from '@material-ui/core/CardActions'; import CardContent from '@material-ui/core/CardContent'; import useConfirmationModalManagement from '../../store/confirmationModal/hooks/useConfirmationModalManagement'; import styles from './styles.module.scss'; const ConfirmationModal = () => { const { isOpened, confirm, decline } = useConfirmationModalManagement(); return ( <Modal open={isOpened} onClose={decline} className={styles.modal} > <Card className={styles.card}> <CardContent> Are you sure? This can't be undone. </CardContent> <CardActions> <Button onClick={confirm}> Yes </Button> <Button onClick={decline}> No </Button> </CardActions> </Card> </Modal> ) } export default ConfirmationModal; |
To start up the whole confirmation flow, we need to call the open function.
1 2 3 4 5 6 7 8 9 10 |
const { open } = useConfirmationModalManagement(); const { deleteCurrentPost } = usePostsManagement(); const handleDeleteButtonClick = async () => { const isConfirmed = await open(); if (isConfirmed) { deleteCurrentPost(); } } |
Writing unit tests for our thunk
At first glance, our thunk might look a bit complicated. To ensure that it works properly, let’s write a unit test for it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import { Store } from '@reduxjs/toolkit'; import confirmationModalThunkActions from './thunk'; import createStore from '../index'; import { AppState } from '../reducers'; import { confirmationModalActions } from './slice'; describe('The open thunk action', () => { let store: Store; let action: ReturnType<typeof confirmationModalThunkActions.open> beforeEach(() => { store = createStore(); action = confirmationModalThunkActions.open() }) describe('when dispatched', () => { it('should open the confirmation modal', () => { action(store.dispatch, store.getState(), { store }); const state: AppState = store.getState(); expect(state.confirmationModal.isOpened).toBe(true); }); it('should resolve to false if declined', async () => { const confirmationPromise = action(store.dispatch, store.getState(), { store }); store.dispatch(confirmationModalActions.decline()); const { payload } = await confirmationPromise; expect(payload).toBe(false); }); it('should resolve to true if confirmed', async () => { const confirmationPromise = action(store.dispatch, store.getState(), { store }); store.dispatch(confirmationModalActions.confirm()); const { payload } = await confirmationPromise; expect(payload).toBe(true); }); }) }) |
Above, we test all the major features of our confirmation modal thunk. All of the tests pass without issues:
1 2 3 4 5 |
The open thunk action when dispatched ✓ should open the confirmation modal ✓ should resolve to false if declined ✓ should resolve to true if confirmed |
Handling the loading state
There is a good chance that the action we want to invoke on confirmation is asynchronous. An idea to handle it gracefully would be to display a loading state inside of our confirmation modal. We would close it only after the asynchronous function finishes.
1 2 3 4 5 6 7 8 9 10 11 12 |
const { open, initiateLoading, close } = useConfirmationModalManagement(); const { deleteCurrentPost } = usePostsManagement(); const handleDeleteButtonClick = async () => { const isConfirmed = await open(); if (isConfirmed) { initiateLoading(); await deleteCurrentPost(); close(); } } |
Feel free to add some error handling to the above solution.
Under the hood, we set the isLoading parameter in the store when the loading is initiated. We then need to handle it in our ConfirmationModal.
Summary
In this article, we’ve created a generic confirmation modal. We’ve looked into a straightforward approach that involves saving functions in the store. Since it is not perfect, we’ve developed a solution that involves thunks. Thanks to it, we’ve been able to avoid saving non-serializable data inside our store. Still, we can open the confirmation modal and react to the user input in any way we need to across our application.
so good
Work like a charm, now i will try implements with notistack 🙂