edoedoedo
edoedoedo

Reputation: 1641

React observable epic with Redux Toolkit and Typescript

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

Answers (3)

Damien Sawyer
Damien Sawyer

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

phry
phry

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

Linda Paiste
Linda Paiste

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

Related Questions