When I teach web development, I always spend time on one of the most elegant moves in the history of software architecture: the shift from Web 1.0 application servers to REST.
Early application servers needed to share session state across machines — a genuinely hard distributed systems problem. People built clever solutions: sticky sessions, shared databases, distributed caches. Then REST came along and didn't solve the problem at all. It eliminated it. By making each request self-contained, stateless by design, the need for shared session state simply vanished. The solution was a reframing.
I think about that move a lot when I look at how modern frontend frameworks handle server state — because I think the same thing is happening again, and most people are still on the "build a better session cache" side of it.
TanStack Query is excellent. If you're using React with component-local state or context, you should probably use it. But understanding why it exists reveals something interesting: the problems it solves are architectural, not universal.
If your app is built around a centralized, entity-based store, you may already have everything TanStack Query gives you. And the only missing piece might be a single helper function.
What TanStack Query Actually Solves
Before dismissing any tool, it's worth being precise about the problem it's solving.
In a typical React app, server state lives in components. Two components that need the same data will independently useEffect their way to it, each triggering its own fetch. There's no shared cache, no way to say "this data is already in flight — don't fetch it again." The result is duplicate requests, inconsistent UI states, and a lot of hand-rolled loading/error handling boilerplate.
TanStack Query solves this with a query cache: a centralized store keyed by query keys, where identical keys share a single fetch lifecycle. It adds background refetching, stale-time configuration, request deduplication, and a clean { data, isLoading, error } API on top.
It's solving, essentially, the absence of a single source of truth for server state.
The Entity Store Already Has One
In Inglorious Store, state lives in a store as entities. An entity is just a plain object with an id and a type. Its behavior — how it responds to events — is defined by its type.
When you need to fetch some data, you model it as an entity:
const entities = {
posts: {
type: 'posts',
status: 'idle', // 'loading' | 'success' | 'error'
items: [],
error: null,
},
}
This entity lives in the store. It's not local to a component. Every component that needs posts reads the same posts entity.
That's deduplication. Not as a feature you opted into — as a consequence of the architecture.
The handleAsync Helper Is All You Need
The remaining question is how to manage the fetch lifecycle: fire the request, set loading state, handle success, handle error. Without a helper, that's repetitive. With one, it collapses to this:
import { handleAsync } from '@inglorious/store'
const types = {
posts: {
...handleAsync('fetchPosts', {
start(entity) {
entity.status = 'loading'
},
async run() {
const res = await fetch('/api/posts')
return res.json()
},
success(entity, posts) {
entity.items = posts
entity.status = 'success'
},
error(entity, err) {
entity.error = err.message
entity.status = 'error'
},
}),
},
}
handleAsync expands into a set of event handlers — fetchPosts, fetchPostsStart, fetchPostsRun, fetchPostsSuccess, fetchPostsError — that you can trigger, test, and extend like any other store behavior.
To trigger a fetch, you notify the store:
store.notify('posts:fetchPosts')
To read the data, you read the entity. In a component:
function PostList() {
const posts = useEntity('posts')
if (posts.status === 'loading') return <p>Loading...</p>
if (posts.status === 'error') return <p>Error: {posts.error}</p>
return <ul>{posts.items.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
The Comparison Side by Side
Here's the same scenario in TanStack Query:
function PostList() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
This is clean, and TanStack deserves credit for how ergonomic it is. But notice what it's doing: it's creating an implicit cache (keyed by ['posts']) to compensate for the fact that there's no shared store. If two PostList components mount, TanStack Query deduplicates the fetch via that cache.
In the entity-based version, there's no need for a cache layer at all. The store is the cache. posts is a single entity. Two components reading from it are just reading the same thing.
This Isn't a New Idea
It's worth stepping back, because the entity store isn't the only architecture that sidesteps this problem.
Angular has always had services — injectable classes that live outside components and centralize logic. An Angular developer who designs their services well, using RxJS's shareReplay(1) to multicast a single HTTP request to multiple subscribers, largely doesn't feel the pain TanStack Query solves. The Angular ecosystem has a TanStack Query adapter, but experienced Angular developers often don't reach for it because their tooling already pushes them toward centralization. The architectural instinct was there from the beginning.
Redux centralizes state, just like the entity store. So why does RTK Query exist?
This is worth being precise about, because it's a genuine objection to the argument. Redux solves the shared state problem — there's one store, one source of truth — but it says nothing about how data gets into that store. You still write thunks or sagas to fire requests, and you still manually dispatch loading, success, and error actions. That boilerplate is real, and RTK Query eliminates it by auto-generating everything from an endpoint definition.
So RTK Query isn't solving the same problem as TanStack Query. TanStack Query compensates for the absence of a shared store. RTK Query eliminates lifecycle boilerplate on top of a store that already exists. These are different problems.
handleAsync is Inglorious Store's answer to that same boilerplate problem. The difference is that RTK Query goes further — it adds cache lifetime management, automatically removing data from the store when no component is subscribed to it anymore. handleAsync doesn't do that. For most applications it doesn't matter, but it's an honest gap.
The broader point is that this pattern — centralize state, reduce fetching to a lifecycle concern — has precedent across frameworks and decades. The entity store is a particularly clean expression of it, but the instinct is not new.
What You'd Still Want
Being precise here matters. There are features TanStack Query provides that handleAsync doesn't out of the box:
Refetch on window focus. When the user tabs back to the app, TanStack Query can silently refetch stale data. You could replicate this with a visibilitychange listener that notifies the store, but it's not automatic.
Stale time and cache invalidation. TanStack lets you say "consider this data stale after 30 seconds." In an entity store, you'd add a fetchedAt field to the entity and check it before deciding whether to fetch. A small utility could wrap that pattern.
Pagination and infinite queries. TanStack has first-class support for these. In an entity store, you'd model paginated state explicitly — page, hasMore, items — which is more verbose but also more transparent.
These are real gaps. But they're gaps you'd fill with a few conventions and perhaps a small utility, not a 40KB library with its own cache, observer system, and devtools.
When You Should Use TanStack Query
If you're building a React app without a centralized store, TanStack Query is the right answer. It provides the shared cache you're missing, with a polished API and a huge ecosystem.
If you're already on Redux Toolkit, RTK Query is worth looking at — it integrates the same ideas directly into the Redux store.
And honestly, even in an entity-based architecture, if your data-fetching needs become complex enough — lots of pagination, optimistic updates, real-time invalidation — reaching for TanStack Query as an explicit caching layer isn't wrong. It just solves a problem you don't have yet.
The Takeaway
Remember REST. It didn't build a better session cache — it reframed the problem until the cache wasn't needed.
The entity store does something similar on the client side. TanStack Query exists because component-local state creates a fragmentation problem: multiple components, multiple fetches, no shared truth. Its query cache is a sophisticated solution to that problem. But the entity store reframes it: state is centralized by design, so the fragmentation never happens. There's no cache to build because there's no incoherence to resolve.
The irony is that REST solved distributed state by embracing statelessness on the server. The entity store solves the client-side version of the same problem by doing the opposite — embracing a single, explicit, stateful store. Same intellectual move (reframe the problem), opposite mechanism.
If your state lives in components, you need TanStack Query. If your state lives in a centralized entity store, you already have deduplication and a single source of truth for free. All you need on top is a clean way to manage the async lifecycle — and handleAsync is that.
The next time you evaluate a library, it's worth asking: what problem is this solving, and do I have that problem? Sometimes the answer is no, and the simpler path is already in front of you.
Top comments (2)
The REST analogy at the top is spot on. "Reframe the problem until the solution becomes unnecessary" is such an underrated approach in frontend architecture.
I've gone back and forth on this in real projects. TanStack Query is amazing DX for apps where you're fetching a lot of independent resources — think dashboards with 15 different widgets each hitting different endpoints. In that world the cache invalidation and background refetch stuff earns its weight fast.
But for most CRUD apps? Yeah, a centralized store with a thin async wrapper handles 90% of what you actually need. The remaining 10% (stale-while-revalidate, optimistic updates) is only worth the extra dependency if you're actually hitting those edge cases.
Where does your entity store approach land on optimistic mutations? That's the one place I always end up reaching for TanStack Query even when the rest of the data layer doesn't need it.
Great point, Kai — and honestly, I hadn't thought about it deeply until now. Optimistic mutations are doable with the current primitives (update the entity immediately, snapshot for rollback, restore on error in the handleAsync error handler), but there's nothing wrapping that pattern automatically the way TanStack Query does.
That's a gap worth closing. I'll see if I can build a thin abstraction on top of handleAsync that handles the optimistic update/rollback lifecycle transparently, so the developer doesn't have to wire it manually. Thanks for the nudge — this is exactly the kind of real-world use case that makes the framework better!