Search code examples
reactjstypescriptreact-hooksredux-saga

Who to load dropdown options from API in react JS with typescript and react saga?


Here is my page, Here I want to load brand option from API. I have written saga attached below:

Action.tsx

 export const getBrandsForDropdown = (request: IPagination) => {
      return {
        type: actions,
        payload: request
      }
    }

Api.tsx

export const getBrandsForDropdown = async () => {
  const page = 1;
  const limit = 1000;
  console.log("get brand drop down");
  const query = `user/master/brands?page=${page}&limit=${limit}`;
  return client(query, { body: null }).then(
    (data) => {
      console.log("get brand drop down in ");
      return { data, error: null };
    },
    (error) => {
      return { data: null, error };
    }
  );
};

Reducer.ts

case actions.GET_BRANDS_DROPDOWN_PENDING:
  return {
    ...state,
    loading: true,
  };
case actions.GET_BRANDS_DROPDOWN_REJECTED:
  return {
    ...state,
    loading: false,
  };
case actions.GET_BRANDS_DROPDOWN_RESOLVED:
  return {
    ...state,
    loading: false,
    brandOptions: action.payload,
  };

Saga.ts

function* getBrandForDropDownSaga(action: HandleGetBrandsForDropdown) {
    yield put(switchGlobalLoader(true));

    yield put(pendingViewBrand());
    try {
        const { data } = yield getBrandsForDropdown();

        yield put(resolvedViewBrand(data));
        yield put(switchGlobalLoader(false));
    } catch (error) {
        yield put(switchGlobalLoader(false));
        return;
    }
}

After this I don't how to call it in my page and get it as a options in brand dropdown


Solution

  • Original Answer: Just Use Thunk

    You can do this with redux-saga but I wouldn't recommend it. redux-thunk is a lot easier to use. Thunk is also built in to @reduxjs/toolkit which makes it even easier.

    There is no need for an IPagination argument because you are always setting the pagination to {page: 1, limit: 1000}

    Try something like this:

    import {
      createAsyncThunk,
      createSlice,
      SerializedError
    } from "@reduxjs/toolkit";
    import { IDropdownOption } from "office-ui-fabric-react";
    import client from ???
    
    // thunk action creator
    export const fetchBrandsForDropdown = createAsyncThunk(
      "fetchBrandsForDropdown",
      async (): Promise<IDropdownOption[]> => {
        const query = `user/master/brands?page=1&limit=1000`;
        return client(query, { body: null });
        // don't catch errors here, let them be thrown
      }
    );
    
    interface State {
      brandOptions: {
        data: IDropdownOption[];
        error: null | SerializedError;
      };
      // can have other properties
    }
    
    const initialState: State = {
      brandOptions: {
        data: [],
        error: null
      }
    };
    
    const slice = createSlice({
      name: "someName",
      initialState,
      reducers: {
        // could add any other case reducers here
      },
      extraReducers: (builder) =>
        builder
          // handle the response from your API by updating the state
          .addCase(fetchBrandsForDropdown.fulfilled, (state, action) => {
            state.brandOptions.data = action.payload;
            state.brandOptions.error = null;
          })
          // handle errors
          .addCase(fetchBrandsForDropdown.rejected, (state, action) => {
            state.brandOptions.error = action.error;
          })
    });
    
    export default slice.reducer;
    

    In your component, kill the brandOptions state and access it from Redux. Load the options when the component mounts with a useEffect.

    const brandOptions = useSelector((state) => state.brandOptions.data);
    
    const dispatch = useDispatch();
    
    useEffect(() => {
      dispatch(fetchBrandsForDropdown());
    }, [dispatch]);
    

    CodeSandbox Link

    Updated: With Saga

    The general idea of how to write the saga is correct in your code.

    1. take the parent asynchronous action.
    2. put a pending action.
    3. call the API to get data.
    4. put a resolved action with the data or a rejected action with an error.

    The biggest mistakes that I'm seeing in your saga are:

    • Catching errors upstream.
    • Mismatched data types.
    • Not wrapping API functions in a call effect.

    Error Handling

    Your brands.api functions are all catching their API errors which means that the Promise will always be resolved. The try/catch in your saga won't have errors to catch.

    If you want to catch the errors in the saga then you need to remove the catch from the functions getBrandsForDropdown etc. You can just return the data directly rather than mapping to { result: data, error: null }. So delete the whole then function. I recommend this approach.

    export const getBrandsForDropdown = async () => {
      const page = 1;
      const limit = 1000;
    
      const query = `user/master/brands?page=${page}&limit=${limit}`;
      return client(query, { body: null });
    }
    

    If you want to keep the current structure of returning a { result, error } object from all API calls then you need to modify the saga to look for an error in the function return.

    function* getBrandForDropDownSaga() {
      yield put(switchGlobalLoader(true));
    
      yield put(pendingGetBrands());
      const { data, error } = yield call(getBrandsForDropdown);
      if (error) {
        yield put(rejectedGetBrands(error.message));
      } else {
        yield put(resolvedGetBrands(data));
      }
      yield put(switchGlobalLoader(false));
    }
    

    Mismatched Data Types

    There's some type mismatching in your reducer and state that you need to address. In some places you are using an array IBrand[] and in others you are using an object { results: IBrand[]; totalItems: number; currentPage: string; }. If you add the return type IState to the reducer then you'll see.

    There's also a mismatch between a single IBrand and an array. I don't know the exact shape of your API response, but getBrandsForDropdown definitely has an array of brands somewhere. Your saga getBrandForDropDownSaga is dispatching resolvedViewBrand(data) which takes a single IBrand instead of resolvedGetBrands(data) which takes an array IBrand[]. If you add return types to the functions in your brands.api file then you'll see these mistakes highlighted by Typescript.

    Don't Repeat Yourself

    You can do a lot of combining in your API and your saga between the getBrands and the getBrandsForDropdown. Getting the brands for the dropdown is just a specific case of getBrands where you set certain arguments: { page: 1, limit: 1000 }.

    export interface IPagination {
      page?: number;
      limit?: number;
      sort?: "ASC" | "DESC";
      column?: string;
    }
    
    export const getBrands = async (request: IPagination): Promise<IBrands> => {
      const res = await axios.get<IBrands>('/user/master/brands', {
        params: request,
      });
      return res.data;
    };
    
    function* coreGetBrandsSaga(request: IPagination) {
      yield put(switchGlobalLoader(true));
      yield put(pendingGetBrands());
      try {
        const data = yield call(getBrands, request);
        yield put(resolvedGetBrands(data));
      } catch (error) {
        yield put(rejectedGetBrands(error?.message));
      }
      yield put(switchGlobalLoader(false));
    }
    
    function* getBrandsSaga(action: HandleGetBrands) {
      const { sort } = action.payload;
      if ( sort ) {
        yield put(setSortBrands(sort));
        // what about column?
      }
      const brandsState = yield select((state: AppState) => state.brands);
    
      const request = {
        // defaults
        page: 1,
        limit: brandsState.rowsPerPage,
        column: brandsState.column,
        // override with action
        ...action.payload,
      }
    
      // the general function can handle the rest
      yield coreGetBrandsSaga(request);
    }
    
    function* getBrandsForDropDownSaga() {
      // handle by the general function, but set certain the request arguments
      yield coreGetBrandsSaga({
        page: 1,
        limit: 1000,
        sort: "ASC",
        column: "name",
      })
    }
    
    export default function* brandsSaga() {
      yield takeLatest(HANDLE_GET_BRANDS, getBrandsSaga);
      yield takeLatest(GET_BRANDS_DROPDOWN, getBrandForDropDownSaga);
      ...
    }
    

    CodeSandbox