Overview
A bridge between TanStack Query and MobX — normalized entities, optimistic mutations, and dirty tracking out of the box.
The Problem
You're building a complex frontend app. You've chosen MobX for its fine-grained reactivity and class-based models. You've chosen TanStack Query for its battle-tested server-state caching. But gluing them together is painful:
-
TanStack Query gives you raw JSON. You want rich, observable class instances with computed properties and methods — not plain objects you have to manually wrap.
-
The same data appears in multiple queries. A
Foldershows up in the sidebar list and on the detail page. Without normalization, updating it in one place doesn't affect the other. You end up with stale UI or complex manual cache synchronization. -
Optimistic mutations are tedious. Creating, updating, and deleting records optimistically requires coordinating between your MobX stores, the TanStack query cache, rollback logic, and invalidation strategies — every single time.
-
Dirty tracking is a DIY project. Knowing which fields a user has changed — for save buttons, discard prompts, or partial PATCH requests — means building your own snapshot/comparison logic on top of MobX.
mobx-query solves all of this in a single, cohesive layer.
What mobx-query Does
Normalized Entity Management
Every query result passes through a centralized entity collection. If Folder #42 appears in both a list query and a detail query, there is one MobX-observable instance shared across both. Updates to that instance are immediately visible everywhere — no manual cache synchronization needed.
// Both queries return the same Folder instance for the same ID
const folders = foldersQuery.useSuspenseQuery(); // Folder[]
const folder = folderQuery.useSuspenseQuery(folderId); // Folder
// Updating a property on the detail view...
folder.name = "New Name";
// ...is instantly reflected in the list view.
// Same object reference. Zero effort.Declarative Queries That Return Entities
Define queries that look like standard TanStack Query options, but return fully hydrated MobX class instances instead of raw data:
const foldersQuery = new QueryMany({
entity: Folder,
queryKey: () => ["folders"],
queryFn: async () => {
const res = await fetch("/api/folders");
return res.json(); // raw JSON in, Folder[] out
},
});You get all of TanStack Query's features — caching, background refetching, stale-while-revalidate — with MobX-observable entities as the output.
Built-in Optimistic Mutations
Three mutation classes cover the most common patterns:
| Class | What it does |
|---|---|
CreateMutation | Instantly adds a new entity to the collection. Rolls back on error. |
UpdateMutation | Tracks dirty state, skips redundant saves, and auto-rolls back on error. |
DeleteMutation | Optimistically hides the entity from all queries. Restores on error. |
All mutations integrate with TanStack Query's mutation cache — you get retry, deduplication, and devtools visibility for free.
Automatic Dirty Tracking
Every entity automatically tracks field-level changes after hydration:
folder.name = "Updated";
console.log(folder.isDirty); // true
folder.reset(); // restores all fields to their server values
console.log(folder.isDirty); // falseThis makes building edit forms trivially easy — your entity is the form model. The UpdateMutation takes advantage of this by automatically skipping mutations when nothing has changed.
How It Fits Together
┌─────────────────────────────────────────────────────┐
│ MQClient │
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ QueryClient │ │ RootStore │ │
│ │ (TanStack) │ │ ┌────────────────────┐ │ │
│ └──────────────┘ │ │ FoldersStore │ │ │
│ │ │ DocumentsStore │ │ │
│ ┌──────────────┐ │ │ ... │ │ │
│ │ Context │ │ └────────────────────┘ │ │
│ │ (custom deps)│ └──────────────────────────┘ │
│ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Entity Collections (per type) │ │
│ │ Folder → Map<id, Folder> │ │
│ │ Document → Map<id, Document> │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘- MQClient is the root container that initializes everything.
- Entity Collections maintain a normalized
Map<id, Entity>per entity type — handling deduplication, hydration, and lifecycle. - RootStore is your application's store tree — where you define queries, mutations, and business logic.
- Context provides shared dependencies (QueryClient + anything custom like a database client) to all queries and mutations automatically.
When to Use mobx-query
mobx-query is a good fit if:
- ✅ You're already using MobX and TanStack Query (or planning to)
- ✅ You have entity-centric data where the same record appears in multiple places
- ✅ You want optimistic updates without writing boilerplate for every mutation
- ✅ You prefer class-based models with encapsulated behavior over raw data objects
It may not be the best fit if:
- ❌ You don't use MobX for client state
- ❌ Your data doesn't have stable identities (IDs)
- ❌ You prefer a purely functional / hook-based architecture without classes