Using RTK Query for API Communication Instead of Hand-Rolled Fetching

TIL that most of my hand-written loading/error/cache state was solving a problem RTK Query already solves, and the switch removed more bugs than it added dependencies.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

The Problem #

Every screen that hit the API had its own useEffect + useState trio for loading/error/data, and every one of them handled race conditions slightly differently — or not at all. Switching tabs quickly on a list screen could show stale data from a request that hadn't resolved yet.

Context #

This was on a dashboard with a lot of list-and-detail screens, each independently fetching from the same Node.js/Express API. The fetching logic wasn't complex per screen, but there were a lot of screens, and each was a slightly different flavor of the same bug.

What I Tried #

Refactoring the shared logic into a useFetch custom hook to at least centralize the loading/error pattern.

What Went Wrong #

The custom hook fixed the boilerplate but not the actual bug class: it still didn't handle out-of-order responses (a slow request for page 1 resolving after a fast request for page 2 and overwriting it), and it gave me no caching, so navigating back to a screen re-fetched everything from scratch every time.

The Solution #

Replaced the hook with RTK Query, defining the API surface as a set of typed endpoints instead of ad hoc fetch calls.

export const api = createApi({
  reducerPath: "api",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Order"],
  endpoints: (builder) => ({
    getOrders: builder.query<Order[], { status: string }>({
      query: ({ status }) => `/orders?status=${status}`,
      providesTags: ["Order"],
    }),
  }),
});

Why It Works #

RTK Query tracks requests by cache key, so a stale in-flight request for a since-changed argument gets superseded automatically instead of racing the new one. Cache invalidation via tags means a mutation can mark related queries stale without me manually tracking which screens need to refetch.

Lessons Learned #

I'd been treating "fetch data and show it" as a simple problem and hand-rolling a worse version of a solved one. Request deduping, cache invalidation and race-condition handling are exactly the kind of unglamorous correctness work that's easy to skip until it silently corrupts a screen's state.

What I Would Do Differently #

I'd reach for a query library on the first screen that fetches from more than one place, instead of waiting until the ad hoc pattern was copy-pasted across a dozen components.

Request deduplication, cache tag invalidation, race conditions in async UI state.