React observable epic with Redux Toolkit and Typescript
Asked Answered
P

3

6

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?

Precincts answered 12/10, 2020 at 15:1 Comment(0)
D
7

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
        )
    )
    ...
);
Dredge answered 13/10, 2020 at 19:18 Comment(5)
Thanks, just one question: 1) is there a way to get rid of the string "auth/loginStart" and get it automatically from authSlice.actions.loginStart?Precincts
Yeah I hate hardcoded strings too. You can do it by creating a dummy action and accessing its type, but you would have to pass a valid payload to create the action. I just looked at this docs page redux-toolkit.js.org/usage/usage-with-typescript and it seems like there is a utility I didn't know about where you can call loginStart.match(action) to check if an action matches the type. That wouldn't work with your ofType pipe, but surely there's a boolean pipe you can use to call that function.Dredge
Yes I read about the match function, however I kinda don't like it because it just checks the payload type and not the true action type AFAIK, so maybe if I have two actions with the same payload type I'm unsure about what it would do... However, I tried trivially with ofType(authSlice.actions.loginStart.type) and it seems to work, do you agree?Precincts
I would think it checks the action type? But if authSlice.actions.loginStart.type works then yeah do that. I didn't realize it had a type property since it's primary a function.Dredge
You where right, as well as @phry, I read the implementation and the filter(authSlice.actions.loginStart.match) does indeed filter the action type and returns the correct typed action, so that is the way to go!Precincts
T
14

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)
      // ...
    })
  )
Tripody answered 15/10, 2020 at 7:3 Comment(0)
D
7

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
        )
    )
    ...
);
Dredge answered 13/10, 2020 at 19:18 Comment(5)
Thanks, just one question: 1) is there a way to get rid of the string "auth/loginStart" and get it automatically from authSlice.actions.loginStart?Precincts
Yeah I hate hardcoded strings too. You can do it by creating a dummy action and accessing its type, but you would have to pass a valid payload to create the action. I just looked at this docs page redux-toolkit.js.org/usage/usage-with-typescript and it seems like there is a utility I didn't know about where you can call loginStart.match(action) to check if an action matches the type. That wouldn't work with your ofType pipe, but surely there's a boolean pipe you can use to call that function.Dredge
Yes I read about the match function, however I kinda don't like it because it just checks the payload type and not the true action type AFAIK, so maybe if I have two actions with the same payload type I'm unsure about what it would do... However, I tried trivially with ofType(authSlice.actions.loginStart.type) and it seems to work, do you agree?Precincts
I would think it checks the action type? But if authSlice.actions.loginStart.type works then yeah do that. I didn't realize it had a type property since it's primary a function.Dredge
You where right, as well as @phry, I read the implementation and the filter(authSlice.actions.loginStart.match) does indeed filter the action type and returns the correct typed action, so that is the way to go!Precincts
D
2

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;

Demello answered 8/6, 2021 at 21:55 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.