Skip to content

Instantly share code, notes, and snippets.

@markerikson
Created July 15, 2019 22:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save markerikson/8cd881db21a7d2a2011de9e317007580 to your computer and use it in GitHub Desktop.
Save markerikson/8cd881db21a7d2a2011de9e317007580 to your computer and use it in GitHub Desktop.
Redux middleware typing issues
import React, {ComponentType} from "React";
import {Middleware, Action, AnyAction} from "redux";
import {createSlice, PayloadAction} from "redux-starter-kit";
import {createSelector} from "reselect";
import {generateUUID} from "utilities";
import {RootState} from "store";
interface DialogDisplayOptions {
singleton: boolean;
}
interface DisplayedDialogEntry {
dialogTypeName: string;
id: string;
props?: object;
dialogOptions?: DialogDisplayOptions;
}
interface CloseDialogPayload {
id: string;
values?: any;
}
export const dialogsRegistry: Record<string, ComponentType<any>> = {};
export function registerDialogType(dialogTypeName: string, dialogType: ComponentType<any>) {
dialogsRegistry[dialogTypeName] = dialogType;
}
const DEFAULT_DIALOG_OPTIONS: DialogDisplayOptions = {
singleton: false,
};
const getDialogOptions = (action: PayloadAction<DisplayedDialogEntry>) => {
return {
...DEFAULT_DIALOG_OPTIONS,
...action.payload.dialogOptions,
};
};
const dialogsSlice = createSlice({
slice: "dialogs",
initialState: [] as DisplayedDialogEntry[],
reducers: {
showDialog(state, action: PayloadAction<DisplayedDialogEntry>) {
const dialogOptions = getDialogOptions(action);
if (dialogOptions.singleton) {
const alreadyShown = state.some(entry => entry.dialogTypeName === action.payload.dialogTypeName);
if (alreadyShown) {
return state;
}
}
state.push(action.payload);
},
closeDialog(state, action: PayloadAction<CloseDialogPayload>) {
return state.filter(entry => entry.id !== action.payload.id);
},
},
});
export const {closeDialog} = dialogsSlice.actions;
const {showDialog: showDialogInternal} = dialogsSlice.actions;
type Resolver = (value?: any) => void;
// Cargo-culted and modified from the Redux type tests file at:
// https://github.com/reduxjs/redux/blob/master/test/typescript/middleware.ts
// under the hope that it would maybe do something useful
type DialogPromiseDispatch = (action: AnyAction) => Promise<any>;
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.com/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);
}
};
};
export function showDialog(dialogTypeName: string, props?: object, dialogOptions?: DialogDisplayOptions) {
const id = generateUUID("dialog");
return showDialogInternal({dialogTypeName, id, props, dialogOptions});
}
export const selectDialogs = createSelector(
[(state: RootState) => state.ui],
ui => ui.dialogs,
);
export default dialogsSlice.reducer;
import store from "./store";
import {showDialog} from "./dialogsRegistry";
async function showTestWindow() {
counter++;
// ideally, this would work cleanly:
const closedPromise = store.dispatch(showDialog("TestDialog", {dialogNumber : counter});
// But, it only interprets dispatch as returning a PayloadAction.
// Instead, I have to have this nasty hack workaround:
const closedPromise = (store.dispatch(showDialog("TestDialog", {dialogNumber: counter})) as any) as Promise<
TestDialogResult
>;
const result = await closedPromise;
console.log("Test dialog result: ", result);
}
import {configureStore, combineReducers, getDefaultMiddleware} from "redux-starter-kit";
import dialogsReducer, {dialogPromiseMiddleware} from "common/dialogs/dialogsRegistry";
const uiReducer = combineReducers({
dialogs: dialogsReducer,
});
const rootReducer = combineReducers({
ui : uiReducer,
});
const store = configureStore({
reducer: rootReducer,
middleware: [...getDefaultMiddleware(), dialogPromiseMiddleware],
});
export type RootState = ReturnType<typeof store.getState>;
export default store;
@markerikson
Copy link
Author

markerikson commented Jul 19, 2019

@phryneas put together a working solution here:

https://codesandbox.io/s/relaxed-rgb-8skfk?fontsize=14&module=%2Fsrc%2Fcommon%2Fdialogs%2Fstore.ts

Key part:

type MySpecialAction = Action<"foobar">;

type MyStore<
  O extends ConfigureStoreOptions<any, any>
> = O extends ConfigureStoreOptions<infer S, infer A>
  ? {
      dispatch: {
        (action: MySpecialAction): Promise<string>;
      };
    } & EnhancedStore<S, A>
  : never;

const storeOptions = {
  reducer: rootReducer,
  middleware: [...getDefaultMiddleware(), dialogPromiseMiddleware]
};

const store: MyStore<typeof storeOptions> = configureStore(storeOptions);

export type RootState = ReturnType<typeof store.getState>;

@phryneas
Copy link

@markerikson Also take a look at this - solution #1 of it is essentially a cleaner version of this, solution #2 there might (if used with connect) more useful: reduxjs/redux-toolkit#160

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment