skip to Main Content

I’m having trouble using Redux in my React application. Some weird loop behavior occurs when dispatching a specific thunk action from within a useEffect inside a given component, even with an empty dependency array. I’ve dispatch this action elsewhere in the code, and works just fine.

I have a pointSlice which has a async action thunk called fetchPoints. Which basically, calls a method that makes use of a axios instance to make an API call:

import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { getPointsService } from "../../services/point/getPointForTeacher";

interface PointState {
  statusCode?: number;
  errors?: Array<any>;
  error?: boolean;
  data: Array<any>;
  status: "idle" | "loading" | "succeeded" | "failed";
}

const initialState: PointState = {
  data: [],
  status: "idle",
  error: false,
};

export const fetchPoints = createAsyncThunk(
  "point/fetchPoints",
  async (
    {
      id,
      month = new Date().getMonth() + 1,
      year = new Date().getFullYear(),
    }: fetchPointsProps,
    { rejectWithValue }
  ) => {
    try {
      const { data } = await getPointsService({ id, month, year });

      return data;
    } catch (err: any) {
      return rejectWithValue(err.response.data);
    }
  }
);

const pointSlice = createSlice({
  name: "point",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPoints.pending, (state) => {
        state.status = "loading";
      })
      .addCase(
        fetchPoints.fulfilled,
        (state, action: PayloadAction<any>) => {
          state.status = "succeeded";
          state.data = action.payload;
        }
      )
      .addCase(
        fetchPoints.rejected,
        (state, action: PayloadAction<any>) => {
          state.status = "failed";
        }
      );
  },
});

export default pointSlice.reducer;

And I have my filterSlice:

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

const initialState = {
  month: localStorage.getItem("filterYear") || new Date().getMonth() + 1,
  year: localStorage.getItem("filterYear") || new Date().getFullYear(),
  userId: "",
};

const filterSlice = createSlice({
  name: "filters",
  initialState,
  reducers: {
    setYearFilter(state, action: PayloadAction<number | string>) {
      state.year = action.payload;
    },
    setMonthFilter(state, action: PayloadAction<number | string>) {
      state.month = action.payload;
    },
    setUserIdFilter(state, action: PayloadAction<string>) {
      state.userId = action.payload;
    },
    clearFilters(state) {
      state.month = new Date().getMonth() + 1;
      state.year = new Date().getFullYear();
      state.userId = "";
      localStorage.removeItem("filterYear");
      localStorage.removeItem("filterMonth");
    },
    saveFilters(state) {
      localStorage.setItem("filterYear", state.year.toString());
      localStorage.setItem("filterMonth", state.month.toString());
    },
  },
});

export const {
  setYearFilter,
  setMonthFilter,
  setUserIdFilter,
  clearFilters,
  saveFilters,
} = filterSlice.actions;

export default filterSlice.reducer;

And my store configuration (trimmed imports):

const rootReducer = combineReducers({
  themeConfig: themeConfigSlice,
  point: pointSlice,
  filters: filtersSlice,
});

export const store = configureStore({
  reducer: rootReducer,
});

export type IRootState = ReturnType<typeof rootReducer>;
export type AppDispatch = typeof store.dispatch;

Don’t bother too much with themeConfig, it’s from the template I’m using for this project, called Vristo

Ok, so in my PointComponent, I’m dispatching fetchPoints inside a useEffect with empty array. While state.point.status is "loading" I return my Loader component, otherwise my CustomDataTable component, which is a modified copy of AltPagination from Vristo.
I’m injecting my data and other props, but most importantly I’m injecting the fetch method as refetch.

const Point = () => {
  const dispatch = useDispatch<AppDispatch>();

  const { data, status, error } = useSelector(
    (state: IRootState) => state.point
  );

  const { userId, month, year } = useSelector(
    (state: IRootState) => state.filters
  );

  const fetch = () =>
    dispatch(
      fetchPoints({ id: "66a3fcc6f290e87b76d99795", month, year })
    );

  useEffect(() => {
    fetch();
  }, []);

  return status === "loading" ? (
    <CustomLoader />
  ) : (
    <CustomDataTable
      pageTitle="Title"
      data={data}
      searchableItens={["date"]}
      refetch={fetch}
      columns={[...]}
      filterByMonth
      filterByYear
    />
  );
};

export default Point;

Here’s how my CustomDataTable is structured (trimmed some blocks):

const CustomDataTable = ({
  data,
  columns,
  searchableItens,
  pageTitle,
  filterByYear = false,
  filterByMonth = false,
  onRowClick,
  refetch,
}: ICustomDataTableProps) => {
  const dispatch = useDispatch<AppDispatch>();

  useEffect(() => {
    dispatch(setPageTitle(pageTitle));
  });

  const [page, setPage] = useState(1);
  const PAGE_SIZES = [10, 20, 30, 50, 100];
  const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
  const [initialRecords, setInitialRecords] = useState(sortBy(data, "id"));
  const [recordsData, setRecordsData] = useState(initialRecords);

  const [search, setSearch] = useState("");
  const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
    columnAccessor: "id",
    direction: "asc",
  });

  const currentDate = new Date();
  const currentYear = currentDate.getFullYear();
  // const currentMonth = currentDate.getMonth() + 1;

  const yearsToFilter = Array.from({ length: 5 }, (_, i) => currentYear - i);

  // const [monthToFilter, setMonthToFilter] = useState(currentYear);
  // const [yearToFilter, setYearToFilter] = useState(currentMonth);

  const filters = useSelector((state: IRootState) => state.filters);

  useEffect(() => {
    setPage(1);
  }, [pageSize]);

  useEffect(() => {
    const from = (page - 1) * pageSize;
    const to = from + pageSize;
    setRecordsData([...initialRecords.slice(from, to)]);
  }, [page, pageSize, initialRecords]);

  useEffect(() => {
    setInitialRecords(() => {
      if (data?.length > 0)
        return data?.filter((item) => {
          if (searchableItens) {
            let found = false;
            for (let searchable of searchableItens) {
              if (
                item?.[searchable]
                  ?.toString()
                  ?.toLowerCase()
                  ?.includes(search.toLowerCase())
              )
                found = true;
            }

            return found;
          }
        });

      return [];
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [search]);

  useEffect(() => {
    const data = sortBy(initialRecords, sortStatus.columnAccessor);
    if (data?.length > 0)
      setInitialRecords(
        sortStatus.direction === "desc" ? data.reverse() : data
      );
    setPage(1);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sortStatus]);

  useEffect(() => {
    if (refetch) refetch();
  }, [filters]);

  return (
    <div>
      <div className="panel mt-6">
        <div className="flex md:items-center md:flex-row flex-col mb-5 gap-5">
          <h5 className="font-semibold text-lg dark:text-white-light">
            {pageTitle}
          </h5>
        </div>

        <div className="ltr:ml-auto rtl:mr-auto panel mt-6 gap-6 flex">
          {searchableItens && (
            <input
              type="text"
              className="form-input w-auto"
              placeholder="Pesquisar..."
              value={search}
              onChange={(e) => setSearch(e.target.value)}
            />
          )}

          {filterByMonth && (
            <select
              className="form-input w-auto"
              onChange={(e) =>
                dispatch(setMonthFilter(parseInt(e.target.value)))
              }
            >
              <option value="">Month</option>
              {...monthOptions}
            </select>
          )}

          {filterByYear && (
            <select
              className="form-input w-auto"
              onChange={(e) =>
                dispatch(setYearFilter(parseInt(e.target.value)))
              }
            >
              <option value="">Year</option>
              {yearsToFilter.map((year) => (
                <option key={year} value={year}>
                  {year}
                </option>
              ))}
            </select>
          )}
        </div>

        <div className="datatables">
          <DataTable
            records={recordsData}
            columns={columns}
            totalRecords={initialRecords.length}
            recordsPerPage={pageSize}
            page={page}
            onPageChange={(p) => setPage(p)}
            recordsPerPageOptions={PAGE_SIZES}
            onRecordsPerPageChange={setPageSize}
            sortStatus={sortStatus}
            onSortStatusChange={setSortStatus}
            onRowClick={onRowClick}
          />
        </div>
      </div>
    </div>
  );
};

export default CustomDataTable;

Bottomline, what’s the idea here? The idea is:
Fetch points, inject in my data table;
If user changes year or month selected option in CustomDataTable, dispatch an update to the filters;
With filters updated, set my useEffect in motion;
Do we have a refetch method? If so, call it;

And the refetch method is just a dispatch of fetchPoints, same as step one, used in my Point component.

Thing is, this doesn’t work as expected.
It’s recursively making API calls. But again, only for refetch.
And I don’t even have to change year or month. It gets stuck in this loop of loading/calling fetchPoints right away.

For now, this is what I’ve tried so far:
Set my useEffect with an empty dependency array. Should run only once, right? Yeah, but no. Gives me the same behavior. As if the function is calling itself.
I’ve also tried to make use of useCallback instead.
Instead of dispatching my action directly from onChange events in select, making use of useState, and feeding them into the dependency array.
Instead of refetch={fetch}, refetch={() => fetch()}

2

Answers


  1. Hard to say without a minimum reproducible example. But I would say that is very likely that somehow the filters ref changes after each fetch and therefore this is executed endlessly in CustomDataTable.

      useEffect(() => {
        if (refetch) refetch();
      }, [filters]);
    

    Keep in mind that the redux state is immutable and a brand new state is returned after each update no matter how minor it is. So if you change anything in the filters state the useEffect will be retriggered.
    To check if this is the case try removing the filters from the dependency array.
    If this doesn’t work please try to create a Minimal, Reproducible Example

    Also as a side-note, it looks like you have quite a few useEffects in CustomDataTable so you might want to check if you need them all and if all the dependency arrays are needed. Additionaly the first useEffect (the one with dispatch(setPageTitle(pageTitle));) doesn’t have a dependency array so is executed on each re-render

    Login or Signup to reply.
  2. Issue

    Sounds like something is remounting repeatedly. I see that Point conditionally renders the CustomDataTable component based on the loading state that the dispatched fetchPoints action updates.

    It would appear that when CustomDataTable mounts it does a few things:

    1. Point conditionally renders CustomDataTable based on the state.point.status value, which is initially false

      const { data, status, error } = useSelector(
        (state: IRootState) => state.point
      );
      
      ...
      
      return status === "loading" ? (
        <CustomLoader />
      ) : (
        <CustomDataTable
          pageTitle="Title"
          data={data}
          searchableItens={["date"]}
          refetch={fetch}
          columns={[...]}
          filterByMonth
          filterByYear
        />
      );
      
    2. CustomDataTable selects the filter state

      const filters = useSelector((state: IRootState) => state.filters);
      
    3. CustomDataTable runs a side-effect to refetch data under two conditions

      1. The useEffect runs because the component mounted
      2. refetch is truthy so it is called
      useEffect(() => {
        if (refetch) refetch();
      }, [filters]);
      
    4. Calling refetch which dispatches fetchPoints in Point and triggers the state.point.status state update and unmounts and mounts CustomDataTable.

    This creates the render-loop condition where you see fetch called over and over again.

    Solution Suggestion

    I’d suggest refactoring the Point component to conditionally render the loading indicator based on the status and conditionally render CustomDataTable on data being available. Initially when state.point.data is undefined, CustomDataTable won’t be rendered until data is fetched, then later is will remain mounted while refetches occur.

    Example:

    const Point = () => {
      const dispatch = useDispatch<AppDispatch>();
    
      const { data, status, error } = useSelector(
        (state: IRootState) => state.point
      );
    
      const { userId, month, year } = useSelector(
        (state: IRootState) => state.filters
      );
    
      const fetch = () =>
        dispatch(
          fetchPoints({ id: "66a3fcc6f290e87b76d99795", month, year })
        );
    
      useEffect(() => {
        fetch();
      }, []);
    
      return (
        <>
          {status === "loading" && <CustomLoader />}
          {data && (
            <CustomDataTable
              pageTitle="Title"
              data={data}
              searchableItems={["date"]}
              refetch={fetch}
              columns={[...]}
              filterByMonth
              filterByYear
            />
          )}
        </>
      );
    };
    

    Update CustomDataTable to not call refetch when it mounts, but rather when the filters are updated.

    • Remove the useEffect hook call in CustomDataTable that calls refetch

    • Call refetch in the handlers when updating filters state, e.g.

      onChange={(e) => {
        dispatch(setMonthFilter(parseInt(e.target.value)));
        if (refetch) refetch();
      }}
      
    const CustomDataTable = ({
      data,
      columns,
      searchableItems,
      pageTitle,
      filterByYear = false,
      filterByMonth = false,
      onRowClick,
      refetch,
    }: ICustomDataTableProps) => {
      const dispatch = useDispatch<AppDispatch>();
    
      useEffect(() => {
        dispatch(setPageTitle(pageTitle));
      }, [dispatch, pageTitle]); // <-- add correct dependency array!
    
      ... this logic unchanged
    
      // Removed "if (refetch) refetch();" effect
    
      return (
        <div>
          <div className="panel mt-6">
            ...
    
            <div className="ltr:ml-auto rtl:mr-auto panel mt-6 gap-6 flex">
              ...
    
              {filterByMonth && (
                <select
                  className="form-input w-auto"
                  onChange={(e) => {
                    dispatch(setMonthFilter(parseInt(e.target.value)));
                    if (refetch) refetch(); // <--
                  }}
                >
                  <option value="">Month</option>
                  {...monthOptions}
                </select>
              )}
    
              {filterByYear && (
                <select
                  className="form-input w-auto"
                  onChange={(e) => {
                    dispatch(setYearFilter(parseInt(e.target.value)));
                    if (refetch) refetch(); // <--
                  }}
                >
                  <option value="">Year</option>
                  {yearsToFilter.map((year) => (
                    <option key={year} value={year}>
                      {year}
                    </option>
                  ))}
                </select>
              )}
            </div>
    
            ...
          </div>
        </div>
      );
    };
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search