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.
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.
Related Concepts #
Request deduplication, cache tag invalidation, race conditions in async UI state.
Related content
useOptimistic auto-reverts on failure — a hand-rolled optimistic hook doesn't
TIL why React's useOptimistic hook doesn't need explicit rollback code when a server call fails, while a manual optimistic-update hook built on plain useState does.
Turning Figma Designs Into Reusable React Components
TIL that building each screen straight from its Figma frame produced pixel-accurate but unreusable components — the fix was designing the component API from the design system's tokens, not from any one screen.
useDebounce hook
A small generic hook for debouncing a fast-changing value — search inputs, resize handlers, anything you don't want firing on every keystroke.