Handling the request in redux saga have no many steps more!

The Problem

In the article on the official docs for redux-saga, we handle the request like that:

// saga.ts

export function* fetchData(action) {
  try {
    yield put({ type: 'FETCHING' })
    const data = yield call(Api.fetchUser, action.payload.url)
    yield put({ type: 'FETCH_SUCCEEDED', data })
  } catch (error) {
    yield put({ type: 'FETCH_FAILED', error })
  }
}

While the reducer must has the code block like switch...case to catch these actions

// reducer.ts

export const reducer (state = {}, action) {
  switch (action.type) {
    case "FETCHING":
      return state = {
        ...state,
        loading: true,
      }
    case "FETCH_SUCCEEDED":
      return state = {
        ...state,
        data: action.payload.data,
      }
    case "FETCH_FAILED":
      return state = {
        ...state,
        error: action.payload.error,
      }
    default:
      return state
  }
}

If in the case we have many the request we must use the many the case with many the action like Fetch to handle the request

The Solution

We will write the reducer act like a middleware to handle the request like FETCH

The used api is random-user-api

The structure for the store folder

  store
    |--- actions
    |--- reducers
    |--- sagas
    |--- utils

Types for Action

Firstly, we write the type for action

// actions/types.ts

export type TFunctionAction<T = any, S = string> = () => {
  type: S;
  payload?: T;
};

export type TObjectAction<T = any, S = string> = ReturnType<
  TFunctionAction<T, S>
>;

Reducers

Writing the func fetchReducer - handle the fetch dispatched action

// reducers/fetchReducer.ts

import { TObjectAction } from "../actions/types";

export const fetchReducer = (reducer: any) => (
  state: any,
  action: TObjectAction<any>
) => {
  const { type } = action;
  if (type.includes("FETCHING")) {
    return (state = {
      ...state,
      isLoading: true
    });
  }

  if (type.includes("SUCCESS")) {
    const { dataKey, data } = action.payload;
    return (state = {
      ...state,
      [dataKey]: data,
      isLoading: false
    });
  }

  if (type.includes("FAILED")) {
    return (state = {
      ...state,
      error: action.payload.error
      isLoading: true
    });
  }

  return reducer(state, action);
};

The function fetchReducer is curry function. The first func receive the child reducer, the second func receive the state and action of child reducer. This func with check the action.type and decide how to impact to the state.

In the code block if (type.includes("SUCCESS")) we notice the dataKey, the dataKey is key object was sent by dispatched action (saga). We use it like a variable to store a fetched data. The dataKey must be unique.

We will delcare the dataKey in reducer.

// reducers/RandomUser.ts

import { User } from "../../types";
import { RandomUserAction } from "../actions/randomUser";
import { TObjectAction } from "../actions/types";

export const randomUserDataKey = "randomUserData";

export type TState = {
  [randomUserDataKey]: {
    result: User[];
  };
  isLoading: boolean;
};

const initialState: TState = {
  [randomUserDataKey]: {
    result: []
  },
  isLoading: false
};

export const randomUserReducer = (
  state = initialState,
  action: TObjectAction
) => {
  switch (action.type) {
    case RandomUserAction.CLEAR_FETCH_RESULTS:
      state = initialState;
      return state;
    default:
      return state;
  }
};

randomUserDataKey above is the dataKey to store fetched data.

Summary all, we have the root reducer.

// reducers/index.ts

import { combineReducers } from "redux";
import { fetchReducer } from "./fetchReducer";

import { randomUserReducer, TState } from "./randomUser";

const rootReducer = combineReducers({
  randomUser: fetchReducer(randomUserReducer) as () => TState,
});

export type AppState = ReturnType<typeof rootReducer>;

export default rootReducer;

The prepared reducer is done!

Actions

Next to is actions

Writing functions to create common actions

// utils/createActionCommon.ts

import { TFunctionAction } from "../actions/types";

type TActionCommon = ReturnType<TFunctionAction<any, string>>;

export const createActionFetching = (rootName: string): TActionCommon => ({
  type: `${rootName}/FETCHING`
});

export const createActionSuccess = <T>(
  rootName: string,
  payload: T
): TActionCommon => ({
  type: `${rootName}/SUCCESS`,
  payload
});

export const createActionFail = (
  rootName: string,
  error: any
): TActionCommon => ({
  type: `${rootName}/FAILED`,
  payload: error
});

export const createNormalAction = <T extends { [x: string]: any }>(
  action: T,
  rootName: string
): T => {
  return Object.keys(action).reduce((acc, cur) => {
    acc[cur] = `${rootName}/${action[cur]}`;
    return acc;
  }, {} as any);
};

What is a rootName? The root name is prefix name for each action for a individual reducer, likes: @user, @employee.

The root name will be written in actions/randomUser.ts.

import { createNormalAction } from "../utils/createCommonAction";
import { TFunctionAction } from "./types";

export const randomUserActionRootName = "@randomUser";

export const RandomUserAction = createNormalAction(
  {
    FETCH: "FETCH_USERS",
    CLEAR_FETCH_RESULTS: "CLEAR_FETCH_RESULTS"
  },
  randomUserActionRootName
);

export const fetchUserRandom: TFunctionAction = () => ({
  type: RandomUserAction.FETCH
});

export const resetUserRandom: TFunctionAction = () => ({
  type: RandomUserAction.CLEAR_FETCH_RESULTS
});

Saga

Now we will write the function handle these fetch actions above.

// utils/handleSagaRequest.ts

import axios, { AxiosRequestConfig } from "axios";
import { put } from "redux-saga/effects";
import {
  createActionFetching,
  createActionFail,
  createActionSuccess
} from "./createCommonAction";

export function* handleSagaRequest(
  request: AxiosRequestConfig,
  dataKey: string,
  actionRootName: string
) {
  try {
    yield put(createActionFetching(actionRootName));
    const response = yield axios(request);
    yield put(
      createActionSuccess(actionRootName, { dataKey, data: response.data })
    );
  } catch (error) {
    yield put(createActionFail(actionRootName, error));
  }
}

in the saga file.

// sagas/randomUser.ts

import { AxiosRequestConfig } from "axios";
import { handleSagaRequest } from "../utils/handleSagaRequest";
import { randomUserDataKey } from "../reducers/randomUser";
import { takeLatest } from "redux-saga/effects";
import {
  RandomUserAction,
  randomUserActionRootName
} from "../actions/randomUser";

function* fetchRandomUser() {
  const request: AxiosRequestConfig = {
    method: "GET",
    url: "https://randomuser.me/api"
  };

  yield handleSagaRequest(request, randomUserDataKey, randomUserActionRootName);
}

export function* randomUserSaga() {
  yield takeLatest(RandomUserAction.FETCH, fetchRandomUser);
}

Config store and component

Config store and apply saga.

import { applyMiddleware, createStore } from "redux";
import createSagaMiddleware from "redux-saga";

import rootReducer from "./reducers";
import randomUser from "./sagas/randomUser";

// Create the saga middleware
const sagaMiddleware = createSagaMiddleware();

// Mount it on the Store
const store = createStore(rootReducer, applyMiddleware(sagaMiddleware));

// Run the saga
sagaMiddleware.run(randomUser);

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

export default store;

In the root of your app.

// src/index.tsx

import { render } from "react-dom";
import { Provider } from "react-redux";
import store from "./store";

import App from "./App";

const rootElement = document.getElementById("root");
render(
  <Provider store={store}>
    <App />
  </Provider>,
  rootElement
);

Write the component.

// src/components/RandomUser.tsx

import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchUserRandom, resetUserRandom } from "../store/actions/randomUser";
import { AppState } from "../store/reducers";

export const RadomUser = () => {
  const { randomUserData, isLoading } = useSelector(
    (state: AppState) => state.randomUser
  );
  const dispatch = useDispatch();

  const handleFetch = () => {
    dispatch(fetchUserRandom());
  };

  const handleClear = () => {
    dispatch(resetUserRandom());
  };

  return (
    <>
      <button onClick={handleFetch}>Fetch the random user</button>
      {isLoading ? (
        <p>Fetching the user random list</p>
      ) : (
        <div>
          <br />
          <button onClick={handleClear}>Reset the result</button>
          <pre>{JSON.stringify(randomUserData, null, 2)}</pre>
        </div>
      )}
    </>
  );
};

The result

When the button Fetch the random user is clicked, we will dispacth these actions:

  • @randomUser/FETCHING.
  • @randomUser/SUCCESS and if failed is will be @randomUser/FAILED.

The function handleSagaRequest will handle all - add/update the fetched data to the state.

Playground

I prepared codesanbox with add more the todo api example.

The conclusion

With this way we don't need care more about the many requests have the same acting.

Comments

Contact for work:Skype
Code from my 💕