Reputation: 1641
I'm not sure how to write a React observable epic with Redux Toolkit and Typescript.
Suppose I have this authSlice:
import { CaseReducer, createSlice, PayloadAction } from "@reduxjs/toolkit";
type AuthState = {
token: string,
loading: boolean,
};
const initialState: AuthState = {
token: "",
loading: false,
};
const loginStart: CaseReducer<AuthState, PayloadAction<{username: string, password: string}>> = (state, action) => ({
...state,
loading: true,
token: "",
});
const loginCompleted: CaseReducer<AuthState, PayloadAction<{token: string}>> = (state, action) => ({
...state,
loading: false,
token: action.payload.token,
});
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginStart,
loginCompleted,
},
});
export default authSlice;
and this store:
import { configureStore } from '@reduxjs/toolkit';
import { combineEpics, createEpicMiddleware } from 'redux-observable';
import authEpic from './epics/authEpic';
import authSlice from './slices/authSlice';
const epicMiddleware = createEpicMiddleware();
export const rootEpic = combineEpics(
authEpic
);
const store = configureStore({
reducer: {
auth: authSlice.reducer,
},
middleware: [epicMiddleware]
});
epicMiddleware.run(rootEpic);
export type RootState = ReturnType<typeof store.getState>;
export default store;
how should I write this authEpic (I hope the purpose is self-explanatory):
import { Action, Observable } from 'redux';
import { ActionsObservable, ofType } from 'redux-observable';
import { ajax } from 'rxjs/ajax';
import { switchMap } from 'rxjs/operators';
import authSlice from '../slices/authSlice';
export default (action$: ActionsObservable<???>) => action$.pipe(
ofType(???), /* should be of type loginStart */
switchMap<???,???>(action => ajax.post( // should be from a loginStart action to {token: string}
"url", {
username: action.payload.username,
password: action.payload.password
}
)),
...
);
I'm totally confused about the ??? that is what should be the types and how redux observable should be linked with redux toolkit.
Any hint?
Upvotes: 6
Views: 11004
Reputation: 5907
I had a read through the redux-toolkit docs and tried to apply it to redux-observable as best I could. This is what I came up with.
import { delay, mapTo} from 'rxjs/operators';
import { ofType } from 'redux-observable';
import { createSlice} from "@reduxjs/toolkit";
const delayTime = 1000
export type pingValues = 'PING' | 'PONG'
export interface PingState {
value: pingValues,
isStarted: boolean,
count: number
}
const initialState: PingState = {
value: 'PING',
isStarted: false,
count: 0
};
export const pingSlice = createSlice({
name: 'ping',
initialState,
reducers: {
// createSlice does some cool things here. It creates an Action Create function (setPing()) and an Action Type, with a type property 'ping/setPing'. It adds that string as ToString() on the function as well which we can use in the ofType() calls with rxjs
setPing: (state => {
state.value = 'PING'
state.isStarted = true
state.count++;
}),
setPong: (state => {
state.value = 'PONG';
state.isStarted = true;
state.count++;
})
},
});
// Epics
export const pingEpic = (action$:any) => action$.pipe(
ofType(setPing), // Pulling out the string 'ping/setPing' from the action creator
delay(delayTime),// Asynchronously wait 1000ms then continue
mapTo(setPong()) // here we're executing the action creator to create an action Type 'plain old javascript object'
);
export const pongEpic = (action$:any) => action$.pipe(
ofType(setPong),
delay(delayTime),
mapTo(setPing())
);
// Export the actionCreators
export const { setPing, setPong } = pingSlice.actions;
// export the reducer
export default pingSlice.reducer;
Upvotes: 2
Reputation: 44078
In redux-toolkit, you should use the action.match
function in a filter
instead of ofType
for a similar workflow, as stated in the documentation.
This example from the docs will work with all RTK actions, no matter if created with createAction
, createSlice
or createAsyncThunk
.
import { createAction, Action } from '@reduxjs/toolkit'
import { Observable } from 'rxjs'
import { map, filter } from 'rxjs/operators'
const increment = createAction<number>('INCREMENT')
export const epic = (actions$: Observable<Action>) =>
actions$.pipe(
filter(increment.match),
map((action) => {
// action.payload can be safely used as number here (and will also be correctly inferred by TypeScript)
// ...
})
)
Upvotes: 14
Reputation: 42160
The problem is that redux-toolkit obscures the actions so it's hard to know what the action types are. Whereas in a traditional redux setup they are just a bunch of constants.
type T = ReturnType<typeof authSlice.actions.loginStart>['type']; // T is string
// have to create an action to find the actual value of the string
const action = authSlice.actions.loginStart({username: "name", password: "pw"});
const type = action.type;
console.log(type);
It appears that the action.type
for the action created by authSlice.actions.loginStart
is "auth/loginStart" and its type is just string
rather than a specific string literal. The formula is ${sliceName}/${reducerName}
. So the ofType
becomes
ofType("auth/loginStart")
Now for the generic annotations. Our authEpic
is taking a login start action and converting it to a login completed action. We can get those two types in a round-about way by looking at authSlice
:
type LoginStartAction = ReturnType<typeof authSlice.actions.loginStart>`)
But this is silly because we already know the action types from when we created authSlice
. The action type is the PayloadAction
inside of your CaseReducer
. Let's alias and export those:
export type LoginStartAction = PayloadAction<{ username: string; password: string }>;
export type LoginCompletedAction = PayloadAction<{ token: string }>;
These are the types that you'll use for the case reducers:
const loginStart: CaseReducer<AuthState, LoginStartAction> = ...
const loginCompleted: CaseReducer<AuthState, LoginCompletedAction> = ...
I'm not too familiar with observables and epics, but I think the typings that you want on your authEpic
are:
export default (action$: ActionsObservable<LoginStartAction>) => action$.pipe(
ofType("auth/loginStart"),
switchMap<LoginStartAction, ObservableInput<LoginCompletedAction>>(
action => ajax.post(
"url", action.payload
)
)
...
);
Upvotes: 7