Search code examples
typescriptreduxredux-toolkitredux-thunk

How to type CreateAsyncThunk response data for proper storage and handling of state?


When writing a project, the Calculator encountered such a problem. I was given the task of dividing the project into modules, which, if necessary, can be replaced by others. Let's take the API as an example. The API code should be written in such a way that it is intuitively clear how to replace it with another one.

Therefore, I decided to type the functions responsible for API requests. Maybe I did it wrong, it would be great if you point out the mistake.

//./api/types.ts

export type HistoryItem = { //This is an interface of item for request, but not respond
  expression: string;
  result: string;
};

export type CalculationRequest<T> = (expression: string) => T[];
export type DeleteRequest = (id: string) => void;
export type GetAllRequest<T> = () => T[];

The idea to create generics was that the API can take some type of data as input and return some type of data. But before passing this data to the state, it must be strongly typed to match the type of the state.

Next, I wrote a useApi hook that simply checks .env and selects the required api and passes fetch functions to the slice.

import { defaultApi } from "@/api";
import {
  CalculationRequest,
  DeleteRequest,
  GetAllRequest,
  HistoryItem,
} from "@/api/types";

export type ApiMethods = {
  calculateExpression: CalculationRequest<HistoryItem>;
  deleteHistoryItem: DeleteRequest;
  fetchHistory: GetAllRequest<HistoryItem>;
};

type UseApiHook = () => ApiMethods;

export const useApi: UseApiHook = () => {
  if (process.env.NEXT_PUBLIC_REACT_APP_API === "default") {
    return defaultApi;
  } else {
    throw new Error("API was not found!");
  }
};

In the slice, I wrap the fetch functions in createAsyncThunk and write their functionality.

const fetchHistory = createAsyncThunk(
  "history/get",
  api.fetchHistory
);
const deleteHistoryItem = createAsyncThunk(
  "history/delete",
  api.deleteHistoryItem
);
const calculateExpression = createAsyncThunk(
  "calculator/get",
  api.calculateExpression
);

const maxNumberOfHistoryItems = 10;

const initialState: CalculatorHistoryState = {
  history: [],
  inputValue: "0",
  fetchError: null,
  status: "idle",
};

const calculatorHistorySlice = createSlice({
  name: "history",
  initialState,
  reducers: {
    addItemToState(state, action) {
      const updatedHistory = [action.payload, ...state.history];
      if (updatedHistory.length < maxNumberOfHistoryItems) {
        return { ...state, history: updatedHistory };
      }
      return {
        ...state,
        history: updatedHistory.slice(0, maxNumberOfHistoryItems),
      };
    },

    removeFromState(state, action) {
      const filteredHistory = state.history.filter((item) => {
        return item._id != action.payload;
      });
      return { ...state, history: filteredHistory };
    },

    setItemToInput(state, action) {
      return { ...state, inputValue: action.payload };
    },
  },
  extraReducers(builder) {
    builder.addCase(HYDRATE, (state, _action) => {
      return {
        ...state,
        // ...action.payload.subject,
      };
    });

    builder.addCase(fetchHistory.fulfilled, (state, { payload }) => {
      state.history = [...payload];
      state.status = "idle";
    });

    builder.addCase(calculateExpression.fulfilled, (state, _action) => {
      return state;
    });

    builder.addCase(deleteHistoryItem.fulfilled, (state, action) => {
      state.history.filter((item) => item._id != action.payload);
    });

    builder.addCase(deleteHistoryItem.rejected, (state, action) => {
      console.log(action);
      return state;
    });
  },
});

The question is that I want to type the data coming from the createAsyncThunk request. For example, when replacing the API, the type of returned data may change, but before entering the state, the data must be formatted according to the state standard, so that it does not cause errors in the future and is intuitively understendable.

export interface StateItem {
  expression: string;
  result: string;
  _id: string;
  __v?: string;
}

At what point in the code should I specify the data typing for the state that is returned from the API?


Solution

  • It seems like the fundamental question here is where to use the HistoryItem type vs. where the use the StateItem type which extends HistoryItem by adding an _id.

    You can tell which type is appropriate by looking at how you use a variable and what TypeScript errors you get.

    I copy and pasted your code into the TypeScript playground and created a fake api variable with type ApiMethods. The important error that I see is the one here:

        builder.addCase(fetchHistory.fulfilled, (state, { payload }) => {
    --->  state.history = [...payload];
          state.status = "idle";
        });
    

    Type 'HistoryItem[]' is not assignable to type 'WritableDraft[]'.

    Property '_id' is missing in type 'HistoryItem' but required in type 'WritableDraft'.

    Your reducer uses _id in a bunch of places so we know that we need state.history to be StateItem[] rather than HistoryItem[]. This error tells us that we also need your fetchHistory thunk to use the StateItem[] type. Your code relies on the expectation that the fetchHistory API call returns an array of items with an _id property (if that's an incorrect assumption then you need to make other changes).

    You need to change the type of your ApiMethods. Probably like this:

    export type ApiMethods = {
      calculateExpression: CalculationRequest<StateItem>;
      deleteHistoryItem: DeleteRequest;
      fetchHistory: GetAllRequest<StateItem>;
    };
    

    But possibly like this, if you want to say that a generic API always adds an _id:

    interface ApiAdded {
        _id: string;
        __v?: string;
    }
    
    export type CalculationRequest<T> = (expression: string) => (T & ApiAdded)[];
    export type DeleteRequest = (id: string) => void;
    export type GetAllRequest<T> = () => (T & ApiAdded)[];
    
    export type ApiMethods = {
        calculateExpression: CalculationRequest<HistoryItem>;
        deleteHistoryItem: DeleteRequest;
        fetchHistory: GetAllRequest<HistoryItem>;
    };
    

    You also have a problem in your deleteHistoryItem.fulfilled case reducer as your action does not have a payload. If it's accurate the the DeleteRequest returns void, you'll need to get the id of the deleted item from the arguments instead of the payload.

    builder.addCase(deleteHistoryItem.fulfilled, (state, action) => {
        state.history.filter((item) => item._id != action.meta.arg);
    });