I've been looking into creating generic modals with React, Redux, and Thunk. Ideally, my state would look like the following:
export interface ConfirmModalState {
isOpened: boolean;
onConfirm: null | Function
}
export const initialConfirmModalState: ConfirmModalState = {
isOpened: false,
onConfirm: null
};
However, this would mean putting non-serializable data into the state, which seems to be highly discouraged.
I've read a great blogpost by markerikson. However, I don't think the proposed solution would work with asynchronous actions and Thunk.
How do you suggest to resolve this issue?
I've been looking into creating generic modals with React, Redux, and Thunk. Ideally, my state would look like the following:
export interface ConfirmModalState {
isOpened: boolean;
onConfirm: null | Function
}
export const initialConfirmModalState: ConfirmModalState = {
isOpened: false,
onConfirm: null
};
However, this would mean putting non-serializable data into the state, which seems to be highly discouraged.
I've read a great blogpost by markerikson. However, I don't think the proposed solution would work with asynchronous actions and Thunk.
How do you suggest to resolve this issue?
Share Improve this question asked Apr 22, 2021 at 12:32 Marcin WanagoMarcin Wanago 6537 silver badges13 bronze badges 1- what if you want different modals on different platforms? – webduvet Commented Apr 23, 2021 at 14:34
2 Answers
Reset to default 10I actually wrote the post that you linked, and I wrote a much-expanded version of that post a couple years later:
Practical Redux, Part 10: Managing Modals and Context Menus.
I've actually implemented a couple variations of this approach myself since I wrote that post, and the best solution I've found is to add a custom middleware that returns a promise when you dispatch a "show modal" action, and resolves the promise with a "return value" when the dialog is closed.
There's an existing implementation of this approach at https://github./AKolodeev/redux-promising-modals . I ended up making my own implementation. I have a partial version of my homegrown approach in a gist at https://gist.github./markerikson/8cd881db21a7d2a2011de9e317007580 , and the middleware looked roughly like:
export const dialogPromiseMiddleware: Middleware<DialogPromiseDispatch> = storeAPI => {
const dialogPromiseResolvers: Record<string, Resolver> = {};
return next => (action: AnyAction) => {
switch (action.type) {
// Had to resort to `toString()` here due to https://github./reduxjs/redux-starter-kit/issues/157
case showDialogInternal.toString(): {
next(action);
let promiseResolve: Resolver;
const dialogPromise = new Promise((resolve: Resolver) => {
promiseResolve = resolve;
});
dialogPromiseResolvers[action.payload.id] = promiseResolve!;
return dialogPromise;
}
case closeDialog.toString(): {
next(action);
const {id, values} = action.payload;
const resolver = dialogPromiseResolvers[id];
if (resolver) {
resolver(values);
}
delete dialogPromiseResolvers[id];
break;
}
default:
return next(action);
}
};
};
(note: I made that gist when I was having some TS syntax issues getting dispatching to work correctly, so it's likely it won't 100% work out of the box. RTK also now includes some .match()
action matching utilities that would be useful here. but, it shows the basic approach.)
The rough usage in a ponent is:
const closedPromise = dispatch(showDialog("TestDialog", {dialogNumber : counter});
const result = await closedPromise
// do something with the result
That way you can write the "on confirm" logic write there in the place that asked for the dialog to be shown in the first place.
Thank you markerikson for providing an answer. This inspired me to create a solution with thunks. Please give me some feedback here :) I will be using hooks and @reduxjs/toolkit in my example.
This is the state of my ConfirmationModal
reducer:
export interface confirmationModalState {
isOpened: boolean;
isConfirmed: boolean;
isCancelled: boolean;
}
export const initialConfirmationModalState: confirmationModalState = {
isOpened: false,
isConfirmed: false,
isCancelled: false,
};
This is the slice (a bination of the reducer and actions):
import { createSlice } from '@reduxjs/toolkit';
import { initialConfirmationModalState } from './state';
const confirmationModalSlice = createSlice({
name: 'controls/confirmationModal',
initialState: initialConfirmationModalState,
reducers: {
open: state => {
state.isOpened = true;
state.isConfirmed = false;
state.isCancelled = false;
},
confirm: state => {
state.isConfirmed = true;
state.isOpened = false;
},
cancel: state => {
state.isCancelled = true;
state.isOpened = false;
},
},
});
export const confirmationModalActions = confirmationModalSlice.actions;
export default confirmationModalSlice;
This is the thunk action for it:
import { createAsyncThunk } from '@reduxjs/toolkit';
import ThunkApiConfig from '../../../types/ThunkApiConfig';
import { AppState } from '../../reducers';
import { confirmationModalActions } from './slice';
const confirmationModalThunkActions = {
open: createAsyncThunk<boolean, void, ThunkApiConfig>(
'controls/confirmationModal',
async (_, { extra, dispatch }) => {
const store = extra.store;
dispatch(confirmationModalActions.open());
return await new Promise<boolean>(resolve => {
store.subscribe(() => {
const state: AppState = store.getState();
if (state.controls.confirmationModal.isConfirmed) {
resolve(true);
}
if (state.controls.confirmationModal.isCancelled) {
resolve(false);
}
});
});
},
),
};
export default confirmationModalThunkActions;
You can notice it uses extra.store
to perform the subscribe
. We need to provide it when creating a store:
import binedReducers from './reducers';
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import { ThunkExtraArguments } from '../types/ThunkExtraArguments';
function createStore() {
const thunkExtraArguments = {} as ThunkExtraArguments;
const customizedMiddleware = getDefaultMiddleware({
thunk: {
extraArgument: thunkExtraArguments,
},
});
const store = configureStore({
reducer: binedReducers,
middleware: customizedMiddleware,
});
thunkExtraArguments.store = store;
return store;
}
export default createStore();
Now, let's create a hook that allows us to dispatch all of the above actions:
import { useDispatch, useSelector } from 'react-redux';
import { AppState } from '../../../reducers';
import { useCallback } from 'react';
import confirmationModalThunkActions from '../thunk';
import { confirmationModalActions } from '../slice';
import { AppDispatch } from '../../../../index';
export function useConfirmationModalState() {
const dispatch: AppDispatch = useDispatch();
const { isOpened } = useSelector((state: AppState) => ({
isOpened: state.controls.confirmationModal.isOpened,
}));
const open = useCallback(() => {
return dispatch(confirmationModalThunkActions.open());
}, [dispatch]);
const confirm = useCallback(() => {
dispatch(confirmationModalActions.confirm());
}, [dispatch]);
const cancel = useCallback(() => {
dispatch(confirmationModalActions.cancel());
}, [dispatch]);
return {
open,
confirm,
cancel,
isOpened,
};
}
(don't forget to attach confirm
and cancel
to the buttons in your modal)
And that's it! We can now dispatch our confirmation modal:
export function usePostControls() {
const { deleteCurrentPost } = usePostsManagement();
const { open } = useConfirmationModalState();
const handleDelete = async () => {
const { payload: isConfirmed } = await open();
if (isConfirmed) {
deleteCurrentPost();
}
};
return {
handleDelete,
};
}