DEV Community

Katy House
Katy House

Posted on • Edited on

RTK Query for incremental load πŸš€

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}`,  
        }),  
    }),
})
Enter fullscreen mode Exit fullscreen mode

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}`,  
        }),  
    }),
})
Enter fullscreen mode Exit fullscreen mode

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 }
                }
            },  
        }),  
    }),
})
Enter fullscreen mode Exit fullscreen mode

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

  1. useLazyQuery returns trigger function with abort method that allows to cancel the query promise
  2. signal from AbortController can be passed into the baseQuery and abort can 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}`,  
        }),  
    }),
})
Enter fullscreen mode Exit fullscreen mode
πŸ‘©β€πŸ’» 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>
}
Enter fullscreen mode Exit fullscreen mode

✨ 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])
Enter fullscreen mode Exit fullscreen mode

πŸ’€ 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>
}
Enter fullscreen mode Exit fullscreen mode

πŸ”₯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)

Collapse
 
ycmjason profile image
YCM Jason

strict mode never fails to annoy us! 🀣