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
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.
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 withdispatch(setPageTitle(pageTitle));
) doesn’t have a dependency array so is executed on each re-renderIssue
Sounds like something is remounting repeatedly. I see that
Point
conditionally renders theCustomDataTable
component based on the loading state that the dispatchedfetchPoints
action updates.It would appear that when
CustomDataTable
mounts it does a few things:Point
conditionally rendersCustomDataTable
based on thestate.point.status
value, which is initiallyfalse
CustomDataTable
selects the filter stateCustomDataTable
runs a side-effect to refetch data under two conditionsuseEffect
runs because the component mountedrefetch
is truthy so it is calledCalling
refetch
which dispatchesfetchPoints
inPoint
and triggers thestate.point.status
state update and unmounts and mountsCustomDataTable
.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 thestatus
and conditionally renderCustomDataTable
ondata
being available. Initially whenstate.point.data
is undefined,CustomDataTable
won’t be rendered until data is fetched, then later is will remain mounted while refetches occur.Example:
Update
CustomDataTable
to not callrefetch
when it mounts, but rather when thefilters
are updated.Remove the
useEffect
hook call inCustomDataTable
that callsrefetch
Call
refetch
in the handlers when updating filters state, e.g.