Disclaimer: I intentionally left out error handling in the code examples to keep the focus on the main concept. π
Imagine you got a lovely React project with a nicely set up API slice using RTK query:
const api = createApi({
reducerPath: 'myCoolApi',
baseQuery: fetchBaseQuery({ baseUrl: '/baseurl' }),
endpoints: (builder) => ({
getItem: builder.query({
query: (id) => `item/${id}`,
}),
}),
})
Now, you need to fetch all existing items. There are thousands of them but luckily, your API supports pagination and RTK query can easily handle that:
const api = createApi({
// ...
endpoints: (builder) => ({
// ...
getItems: builder.query({
query: (page) => `items?page=${page}`,
}),
}),
})
So far, so good. But what if the client needs everything at once? π¬
π‘ Idea #1: Use queryFn and Manually Update Cache
One way to fetch all items is to loop through pages using queryFn and update the cache manually:
const api = createApi({
// ...
endpoints: (builder) => ({
// ...
getItems: builder.query({
queryFn: async (arg, queryApi, extraOptions, baseQuery) => {
let allData = [];
let page = 0;
let total = null;
try {
while (allData.length < total) {
const res = await baseQuery({url: `/items?page=${page}`});
allData = [...allData, ...res.data.items];
page++;
total = res.data.total;
// manually updating the cache
queryApi.dispatch(api.util.upsertQueryData("getItems", arg, allData));
}
return {data: allData}
} catch (error) {
return { error }
}
},
}),
}),
})
This works wellβdata starts displaying as it loads, and you could even show a progress bar. Great success! π
π But hereβs the catch...
What if the user finds what they need early and doesnβt want to keep loading another 10k items? We need a way to stop the request.
π‘ Idea #2: Abort Requests with useLazyQuery
There are a few ways to abort requests in RTK Query
-
useLazyQueryreturnstriggerfunction withabortmethod that allows to cancel the query promise -
signalfromAbortControllercan be passed into thebaseQueryandabortcan be triggered manually.
Both options will cancel the ongoing request by throwing AbortError and allow us to exit the loop early.
Using useLazyQuery simplifies the API slice and cache requests per page while shifting the responsibility of bundling data together to the React component.
π§ Updated API Slice:
const api = createApi({
// ...
endpoints: (builder) => ({
// ...
getItems: builder.query({
query: (page) => `items?page=${page}`,
}),
}),
})
π©βπ» React Component Handling the Fetching:
const MyCoolComponent = () => {
const [data, setData] = useState([]);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(null);
const [trigger] = useLazyGetItems();
const triggerRef = useRef();
const handleCancel = () => {
if (triggerRef.current) {
triggerRef.current.abort();
}
}
const fetchData = useCallback(async (currentPage) => {
try {
triggerRef.current = trigger(currentPage);
const res = await triggerRef.current;
setData(prev => ([...prev, res.data.items]));
setPage(currentPage + 1);
setTotal(res.data.total);
}
}, [trigger])
useEffect(() => {
if (data.length < total) {
fetchData(page);
}
}, [page, data, total]);
return <div></div>
}
β¨ Whatβs better now?
β
Users can cancel anytime.
β
API requests stop early instead of loading unnecessary data.
But waitβ¦ Strict Mode strikes again π©
Using React StrictMode is highly recommended to catch potential issues. However, it mounts components twice, which in our case means:
- Component mounts β Fetch starts (
page = 0). - Component unmounts (but fetch is still ongoing).
- Component mounts again β Fetch starts again (
page = 0). - First fetch resolves β State updates.
- Second fetch resolves β Duplicate data!
π βLetβs just cancel requests on unmount! What can possibly go wrong?β
useEffect(() => {
fetchData(page);
() => handleCancel();
}, [page])
π Surprise! Aborted requests were still cached, meaning when the next request fired, it also returned an AbortError instead of fetching fresh data. Back to the drawing board!
π‘ Idea #3: The Final Solution β Use useQuery with skip
Instead of handling the fetching manually, we can use useQuery with the skip parameter to control when fetching stops. We keep the React component responsible for bundling the data and the fetching and add a cancelled state that will determine if the fetching needs to stop:
π§ Final Component:
const MyCoolComponent = () => {
const [cancelled, setCancelled] = useState(false);
const [loadedData, setLoadedData] = useState([]);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(null);
const { data } = useGetItemsQuery(page, { skip: cancelled || data.length === total });
const handleCancel = () => {
setCancelled(true);
}
useEffect(() => {
setLoadedData(prev => [...prev, ...data.items]);
setPage(prev => prev + 1);
setTotal(data.total)
}, [data]);
return <div></div>
}
π₯Why is this better?
β
No duplicate requests in Strict Mode.
β
No manual cache updates needed.
β
Clean API slice without extra logic
β
Users can cancel whenever they want.
β
Keeps track of progress for a better UX.
π Final Thoughts
RTK Query makes incremental loading easy, but handling cancellations can be tricky. Using queryFn works in simple cases though it doesn't provide the best UX. Using useLazyQuery can become complicated when using StrictMode.
By using useQuery with skip, we get smooth, controlled data fetching while allowing users to stop loading anytime.
This was a fun issue to solve and definitely got my brain into a twist! π Let me know if youβve run into similar challenges or have feedback on this solution! π
Top comments (1)
strict mode never fails to annoy us! π€£