@mateosuarezdev/query
v1.0.19
Published
Observable-based query and cache management with built-in incremental sync and offline-first persistence. React hooks included
Maintainers
Readme
Query
A next-generation query and cache management system for React and React Native applications. Built on Legend State observables with a pure event-driven architecture, incremental sync, and production-grade offline capabilities.
Why Query?
- 🚀 Observable-based reactivity - Eliminates unnecessary re-renders with fine-grained updates
- 🔄 Incremental sync built-in - Delta-based updates with declarative merge patterns
- 📴 True offline-first - Pluggable persistence (IndexedDB on web, expo-sqlite / MMKV on React Native)
- 🎯 Zero memory leaks - Pure event-driven architecture with automatic cleanup
- 💎 Full type inference - End-to-end TypeScript safety
- ⚡ Battle-tested patterns - Exponential backoff, smart caching, network-aware behavior
- 📱 Cross-platform - Works on web and React Native via custom store/network adapters
How Query Compares
| Feature | Query | Traditional Libraries | | ----------------------- | ---------------------------------- | -------------------------------- | | Reactivity | Observable-based (fine-grained) | State-based (component-level) | | Incremental Sync | Built-in with declarative API | Manual implementation required | | Offline Persistence | Pluggable (IndexedDB / expo-sqlite / MMKV) | Plugin ecosystem or manual setup | | Memory Safety | Event-driven with auto-cleanup | Varies by implementation | | Type Inference | Full (TData, TDelta) | Varies by library | | Real-Time Support | Direct observable updates | Polling or manual integration | | Learning Curve | Familiar if using observables | Depends on library patterns |
Installation
Web:
npm install @mateosuarezdev/query @legendapp/state@beta @mateosuarezdev/event-emitter idb-keyvalReact Native (Expo):
npm install @mateosuarezdev/query @legendapp/state@beta @mateosuarezdev/event-emitter
# idb-keyval is not needed — pass a custom store adapter instead (see React Native section below)Why peer dependencies?
Query externalizes these dependencies to give you maximum flexibility:
@legendapp/state- Use Legend State throughout your app for global state management, sharing the same instance@mateosuarezdev/event-emitter- Reuse the same event system across your applicationidb-keyval- Optional. Used as the default persistence store on web. Not required when providing a customstoreadapter (e.g. React Native)
This approach keeps your bundle size minimal and gives you full control over shared dependencies.
Quick Start
Create queryService isntance and useQuery hook
import { createUseQuery, QueryService } from "@mateosuarezdev/query";
// somewhere like src/query
export const queryService = new QueryService({
appName: "claire",
debug: false,
});
export const useQuery = createUseQuery(queryService);Standard usage
function PostsList() {
const { data, status, invalidate } = useQuery({
key: ["posts"],
get: async () => {
const res = await fetch("/api/posts");
return res.json();
},
offline: true,
invalidateAfterMinutes: 5,
});
if (status === "loading") return <div>Loading...</div>;
if (status === "error") return <div>Error loading posts</div>;
return (
<div>
{data.map((post) => (
<Post key={post.id} {...post} />
))}
<button onClick={invalidate}>Refresh</button>
</div>
);
}React Native
Query works on React Native via two optional constructor parameters: store (replaces IndexedDB) and network (replaces navigator/window).
How persistence works
The service serializes the entire cache — all query data, invalidated keys, and version metadata — into a single JSON blob stored under one key (${appName}-cache-next). On startup it reads that blob once to hydrate the in-memory Legend State observable; from that point all reads and writes go through memory. Storage is only written back via a debounced flush.
expo-sqlite is the right backend because the cache blob can grow large — AsyncStorage and similar stores hit size and performance limits with large payloads. The cache table schema (id, key, value) stores one row in practice today, but is structured to support per-key storage later if ever needed.
expo-sqlite adapter
import * as SQLite from "expo-sqlite";
import NetInfo from "@react-native-community/netinfo";
import {
PersistenceStore,
NetworkAdapter,
QueryService,
createUseQuery,
} from "@mateosuarezdev/query";
// --- persistence ---
const db = SQLite.openDatabaseSync("my-app-cache.db");
db.execSync(`
CREATE TABLE IF NOT EXISTS cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL
)
`);
const sqliteStore: PersistenceStore = {
get: async (key) => {
const row = db.getFirstSync<{ value: string }>(
"SELECT value FROM cache WHERE key = ?",
[key],
);
return row ? JSON.parse(row.value) : undefined;
},
set: async (key, value) => {
db.runSync(
"INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)",
[key, JSON.stringify(value)],
);
},
del: async (key) => {
db.runSync("DELETE FROM cache WHERE key = ?", [key]);
},
};
// --- network ---
let isConnected = false;
const onlineListeners = new Set<() => void>();
function updateConnectivity(online: boolean) {
const reconnected = online && !isConnected;
isConnected = online;
if (reconnected) onlineListeners.forEach((listener) => listener());
}
NetInfo.fetch().then((state) => {
updateConnectivity(state.isConnected ?? false);
});
NetInfo.addEventListener((state) => {
updateConnectivity(state.isConnected ?? false);
});
const netAdapter: NetworkAdapter = {
isOnline: () => isConnected,
onOnline: (listener) => {
onlineListeners.add(listener);
return () => onlineListeners.delete(listener);
},
};
// --- service ---
export const queryService = new QueryService({
appName: "my-app",
store: sqliteStore,
network: netAdapter,
});
export const useQuery = createUseQuery(queryService);Usage in components is identical to the web version — the adapter difference is entirely at setup time.
Incremental Sync - The Game Changer
Instead of refetching all data, sync only what changed:
import { defaultMergeArray } from "@mateosuarezdev/query";
type Post = { id: string; title: string; content: string };
type PostDelta = { modified: Post[]; deleted: string[] };
const { data, sync, invalidate } = useQuery({
key: ["posts"],
// Full data fetch (initial load)
get: async () => {
const res = await fetch("/api/posts");
return res.json() as Post[];
},
// Delta fetch (subsequent updates)
sync: async (lastSync) => {
const res = await fetch(`/api/posts?since=${lastSync}`);
return res.json() as PostDelta;
},
// Built-in merge for arrays with { id } - handles insert, update, delete
merge: defaultMergeArray,
// Optional: notification after sync completes
onSync: (mergedData) => {
console.log("Sync complete!", mergedData.length, "posts");
},
invalidateAfterMinutes: 5, // Auto-sync every 5 minutes
});
// Manual operations
invalidate(); // Force full refetch
sync(); // Force delta syncBenefits:
- ✅ 90% less bandwidth - Only fetch what changed
- ✅ Faster updates - Smaller payloads, quicker response
- ✅ Better UX - Instant feedback, no full reloads
- ✅ Type-safe - Full inference from
synctomerge - ✅ Zero boilerplate - Use
defaultMergeArrayfor common cases
Note: For complex data structures, you can define custom merge logic. See Custom Merge Logic for examples.
Core Features
Smart Caching with Observable State
Built on Legend State for ultra-efficient reactivity:
const { data, update } = useQuery({
key: ["settings"],
get: () => fetchSettings(),
});
// Direct observable mutations (no re-render until accessed)
update((settings) => {
settings.theme.set("dark"); // Only components using theme re-render
settings.language.set("en"); // Only components using language re-render
});Offline-First with Debounced Persistence
Persistence is opt-in per query. offline: true writes that query to IndexedDB
on web or SQLite under Expo, so a returning user sees it immediately while it
refreshes behind them — which is worth doing for anything they open every
session, and not worth doing for a one-off read or a search result.
It defaults to false because every persisted query lives in one stored
value. A write therefore costs what the whole cache weighs rather than what
changed, so one large persisted list slows every unrelated write. Persisting by
default also puts data on disk that the caller never decided to keep, which
matters when the query holds anything personal.
useQuery({
key: ["drafts"],
get: () => fetchDrafts(),
offline: true, // Persists to IndexedDB
});
// Multiple updates within 500ms = single IndexedDB write
update1(); // Schedule persist
update2(); // Cancel previous, schedule new
update3(); // Cancel previous, schedule new
// ... 500ms later: single persist operationShaping What Gets Persisted
onPersist receives a query's data on its way to storage, and what it returns is
what gets written. Memory is left alone — the component keeps rendering exactly
what it had.
useQuery({
key: ["commissions"],
get: () => fetchCommissions({ limit: 10 }),
offline: true,
onPersist: (data) => data.slice(0, 100), // Store 100 rows, render all of them
});Why it exists. Memory and storage want different sizes, and one value can't
satisfy both. A list that accumulates through update is entirely the working
set while someone is scrolling it, so trimming it in memory takes rows out from
under them. Storage has the opposite problem: it outlives the session, and the
whole cache is a single stored value, so one unbounded list makes every unrelated
write more expensive. Only the persisted copy needs a ceiling — onPersist is
where to put it, instead of compromising the in-memory copy to protect the disk.
Trimming is the common case, but anything that narrows works. Dropping a field you would rather not leave on disk is the other one:
onPersist: ({ inviteToken, ...rest }) => rest,When it runs. Only when offline: true, since there is otherwise nothing to
write, and on the debounced flush rather than on every change — several rapid
updates produce one call. Keep it cheap and pure: a slice, a filter, an
omitted key. It is not a place to sort, fetch, or read anything outside data.
What it is not. It does not run in reverse on the way back in. Whatever it returns is what a later session hydrates and renders, so it has to remain a valid value for that query: a shorter list is, a summary object is not.
Pattern Matching Invalidation
Invalidate multiple related queries at once:
// Invalidate all user queries
await queryService.invalidateQueries(["user"]);
// Invalidate specific user
await queryService.invalidateQueries(["user", "123"]);
// Invalidate all posts by status
await queryService.invalidateQueries(["posts", { status: "draft" }]);
// Same with sync
await queryService.syncQueries(["posts"]); // Sync all postsEvent-Driven Callbacks (Memory Safe)
All callbacks flow through event system - no memory leaks. Every instance watching a key receives every event, so two components rendering the same query both hear about a change.
onBeforeSet is the odd one out: it fires before the write rather than
after, which is what makes it useful. Anything you can only learn from the
current state — a scroll position about to shift, a measurement about to go
stale, an element about to be replaced — is readable there and nowhere else.
It runs synchronously inside the write, so keep it to reading.
It fires for data arriving from outside: the first load, and every
invalidation. Not for an update of your own — you already have that moment,
on the line above your own call — and not for a sync, whose result is your
merge function's rather than the query's.
useQuery({
key: ["notifications"],
get: () => fetchNotifications(),
onBeforeSet: (data, { initial }) => {
// Called immediately BEFORE the data is set, with the data it is
// about to be set to and nothing yet re-rendered. The only moment
// the old world is still there to read.
rememberScrollPosition();
},
onLoad: (data) => {
// Called when data loads
// Automatically cleaned up on unmount ✅
console.log("Loaded", data.length, "notifications");
},
onInvalidate: (data) => {
// Called after refetch
console.log("Refreshed notifications");
},
onSync: (data) => {
// Called after delta sync
console.log("Synced notifications");
},
});Automatic Retries & One-Time Operations
All queries have built-in retry logic with exponential backoff. For one-time operations outside of queries, use the queryInPlace and mutateInPlace helpers:
import { queryInPlace, mutateInPlace } from "@mateosuarezdev/query";
// One-time fetch with retries
const { error, result } = await queryInPlace({
getFn: async () => {
const res = await fetch("/api/analytics");
return res.json();
},
retries: 3,
});
// One-time mutation
const { error } = await mutateInPlace({
fn: async () => {
await fetch("/api/posts", { method: "POST", body: JSON.stringify(data) });
},
onSuccess: () => {
queryService.invalidateQueries(["posts"]);
},
});These helpers are also used internally by the query system itself, ensuring consistent error handling and retry behavior throughout your app.
Network-Aware Behavior
Handles offline/online transitions automatically:
useQuery({
key: ["feed"],
sync: (lastSync) => fetchFeedDelta(lastSync),
merge: defaultMergeArray,
});
// When offline:
// - Returns cached data
// - Queues sync/invalidation requests
// When back online:
// - Automatically processes queued requests
// - Syncs all stale queriesAPI Reference
useQuery<TData, TDelta>(options)
Options:
{
key: QueryKey; // Query identifier
get?: (lastSync?: string) => Promise<TData>; // Fetch full data
sync?: (lastSync: string) => Promise<TDelta>; // Fetch delta
merge?: (delta: TDelta, update) => void; // Merge delta
offline?: boolean; // Persist this query (default: false)
onPersist?: (data) => data; // Reshape data before it is stored
enabled?: boolean; // Enable/disable (default: true)
invalidateAfterMinutes?: number; // Auto-invalidate (default: 5)
onBeforeSet?: (data, { initial }) => void; // Called just before data is set
onLoad?: (data: TData) => void; // Called on load
onInvalidate?: (data: TData) => void; // Called on invalidate
onSync?: (data: TData) => void; // Called on sync
}Returns:
{
data: TData | null; // Query data
status: 'loading' | 'success' | 'error' | 'invalidating' | 'syncing' | 'stale';
lastUpdate: number; // Timestamp of last update
lastSync: string; // ISO string of last sync
isInvalidated: boolean; // Whether query needs refresh
invalidate: () => void; // Force full refetch
sync: () => void; // Force delta sync (if sync provided)
update: (updater) => void; // Update cache directly
}new QueryService(options)
new QueryService({
appName: string; // Required. Used as the storage key prefix.
debug?: boolean; // Log cache operations (default: false)
testMode?: boolean; // Clears persisted cache on init (default: false)
store?: PersistenceStore; // Custom storage adapter. Defaults to idb-keyval on web.
network?: NetworkAdapter; // Custom network adapter. Defaults to navigator/window on web.
})Both PersistenceStore and NetworkAdapter are exported from the package:
interface PersistenceStore {
get(key: string): Promise<any>;
set(key: string, value: any): Promise<void>;
del(key: string): Promise<void>;
}
interface NetworkAdapter {
isOnline(): boolean;
onOnline(callback: () => void): () => void; // returns unsubscribe
}The adapter is also the boundary for cold queries: when isOnline() is
false, registering a query creates its loading cache entry but does not call
get. It is fetched once onOnline reports a reconnection. On React Native,
use @react-native-community/netinfo and initialize the adapter as offline
until its first state is known.
queryService
Global service for query management:
// Initialize cache
await queryService.initCache();
// Add query programmatically
await queryService.addQuery({
key: ["data"],
get: () => fetchData(),
offline: true,
});
// Invalidate queries
await queryService.invalidateQueries(["data"]);
await queryService.invalidateQueries([]); // Invalidate all
// Sync queries
await queryService.syncQueries(["posts"]);
// Update query data
queryService.updateQuery(["data"], (dataObs) => {
dataObs.value.set(newValue);
});Common Patterns
List + Detail
// List query
const { data: posts } = useQuery({
key: ["posts"],
get: () => fetchPosts(),
sync: (lastSync) => fetchPostsDelta(lastSync),
merge: defaultMergeArray,
});
// Detail query
const { data: post } = useQuery({
key: ["post", postId],
get: () => fetchPost(postId),
});
// After updating a post
async function updatePost(data) {
await api.updatePost(postId, data);
await queryService.invalidateQueries(["posts"]); // Refresh list
await queryService.invalidateQueries(["post", postId]); // Refresh detail
}Pagination
function PostsList() {
const [page, setPage] = useState(1);
const { data, status } = useQuery({
key: ["posts", { page }],
get: () => fetchPosts(page),
});
return (
<>
{data?.posts.map((post) => (
<Post key={post.id} {...post} />
))}
<button onClick={() => setPage((p) => p + 1)}>Next Page</button>
</>
);
}Load More / Infinite Lists
The section above replaces one page with the next. When rows should accumulate
instead, do not add a query per page — fetch the next page with queryInPlace
and append it to the entry already on screen:
function CommissionsList() {
const [canLoadMore, setCanLoadMore] = useState(true);
const commissions = useQuery({
key: ["commissions"],
get: () => fetchCommissions({ limit: 10 }),
});
async function loadMore() {
const lastItem = commissions.data?.at(-1);
if (!lastItem) return;
const { result, error } = await queryInPlace({
getFn: () => fetchCommissions({ limit: 10, after: lastItem.id }),
});
if (error) return;
// A page shorter than the limit is the last one.
if (result.length < 10) setCanLoadMore(false);
commissions.update((obs) => {
obs.set((prev) => [...prev, ...result]);
});
}
return (
<>
{commissions.data?.map((item) => <Row key={item.id} {...item} />)}
{canLoadMore && <button onClick={loadMore}>Load more</button>}
</>
);
}Because a cached entry is an observable, appending is a write rather than a new
cache entry. So there are no page objects to flatten, no key that changes as the
user scrolls, and no cursor held in state — the cursor is the last row already
rendered. The only extra state is canLoadMore, and the page answers it.
Keep the growing list in the query itself. An array of pages kept beside it is a second copy of server data that has to be reconciled on every refetch, which is the problem the observable cache exists to avoid.
The list is unbounded in memory, deliberately — every loaded row is what the user is scrolling, so nothing takes rows away mid-session.
Storage is the half that needs a limit, since it outlives the session. If this
list is persisted, cap it with onPersist:
const commissions = useQuery({
key: ["commissions"],
get: () => fetchCommissions({ limit: 10 }),
offline: true,
onPersist: (data) => data.slice(0, 100),
});Keep the head, not the tail. Pagination starts at the most relevant end and walks away from it, so the rows loaded first are the ones worth keeping — for a feed, a chat, or a payment history alike.
A reload then shows a shorter list than the session that wrote it, and loading more continues from there. That is how every app of this shape behaves, and it only reads as data loss if nobody expected it.
Dependent Queries
function UserPosts({ userId }) {
// First get user
const { data: user } = useQuery({
key: ["user", userId],
get: () => fetchUser(userId),
});
// Then get their posts (waits for user)
const { data: posts } = useQuery({
key: ["posts", { authorId: userId }],
get: () => fetchUserPosts(userId),
enabled: !!user, // Only run when user loaded
});
if (!user) return <div>Loading user...</div>;
if (!posts) return <div>Loading posts...</div>;
return <PostsList posts={posts} author={user} />;
}Optimistic Updates
const { data, update, invalidate } = useQuery({
key: ["todos"],
get: () => fetchTodos(),
});
async function toggleTodo(todoId) {
// Optimistically update UI
update((todosObs) => {
const todo = todosObs.find((t) => t.id.peek() === todoId);
if (todo) {
todo.completed.set(!todo.completed.peek());
}
});
// Send request
await api.toggleTodo(todoId);
// Refresh from server to confirm
invalidate();
}Prefetching
function PostLink({ postId }) {
const prefetch = () => {
queryService.addQuery({
key: ["post", postId],
get: () => fetchPost(postId),
});
};
return (
<a href={`/posts/${postId}`} onMouseEnter={prefetch}>
View Post
</a>
);
}Real-Time Updates
For real-time features (WebSockets, Server-Sent Events), directly update the observable cache - it's more powerful than sync and still automatically persists:
type Message = { id: string; text: string; timestamp: string };
const { data: messages, update } = useQuery({
key: ["chat", roomId],
get: () => fetchMessages(roomId),
offline: true,
});
// Connect to WebSocket
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data) as Message;
// Directly update the observable - instant UI update + auto-persists!
update((messagesObs) => {
messagesObs.push(message);
});
playNotification();
};
return () => ws.close();
}, [roomId]);Why this is better than sync:
- ✅ Instant updates - No polling, no delay
- ✅ Still auto-persists - Offline support included
- ✅ More efficient - Push-based, not pull-based
- ✅ Full control - Handle any real-time protocol (WebSockets, SSE, Firebase, etc.)
Note: The sync method is designed for periodic background synchronization, not real-time updates.
Advanced Usage
Custom Merge Logic
For complex data structures:
type AppState = {
users: Record<string, User>;
settings: Settings;
metadata: Metadata;
};
type StateDelta = {
usersModified: User[];
usersDeleted: string[];
settings?: Settings;
metadata?: Metadata;
};
useQuery({
key: ["app-state"],
sync: (lastSync) => fetchStateDelta(lastSync),
merge: (delta, update) => {
update((stateObs) => {
// Update users object
delta.usersModified.forEach((user) => {
stateObs.users[user.id].set(user);
});
delta.usersDeleted.forEach((id) => {
delete stateObs.users[id];
});
// Update settings if changed
if (delta.settings) {
stateObs.settings.set(delta.settings);
}
// Update metadata if changed
if (delta.metadata) {
stateObs.metadata.set(delta.metadata);
}
});
},
});Conditional Queries
function Profile({ userId }) {
const { data: user } = useQuery({
key: ["user", userId],
get: () => fetchUser(userId),
enabled: !!userId, // Only run if userId exists
});
const { data: premium } = useQuery({
key: ["premium", userId],
get: () => fetchPremiumStatus(userId),
enabled: !!user && user.isPremium, // Only for premium users
});
}Server Response Transformation
When your server returns data in a different format:
// Server returns: { data: Post[], deletedIds: string[], timestamp: string }
// We need: { modified: Post[], deleted: string[] }
useQuery({
key: ["posts"],
sync: async (lastSync) => {
const res = await fetch(`/api/posts?since=${lastSync}`);
const { data, deletedIds } = await res.json();
// Transform to expected format
return {
modified: data,
deleted: deletedIds,
};
},
merge: defaultMergeArray,
});Performance Optimization
Debounced Persistence
Automatic batching reduces IndexedDB writes:
// Multiple updates in quick succession
update1(); // t=0ms - schedule persist at t=500ms
update2(); // t=100ms - cancel previous, schedule at t=600ms
update3(); // t=200ms - cancel previous, schedule at t=700ms
update4(); // t=300ms - cancel previous, schedule at t=800ms
// t=800ms - Single persist operation (4 updates → 1 write)Selective Offline Persistence
Only persist what you need offline:
// Critical data - persist
useQuery({
key: ["user-data"],
get: () => fetchUserData(),
offline: true, // Saved to IndexedDB
});
// Transient data - don't persist
useQuery({
key: ["search-results"],
get: () => searchPosts(query),
offline: false, // Memory only — the default, so it can also be omitted
});Pattern-Based Invalidation
Efficiently refresh related queries:
// Instead of invalidating each query individually:
await queryService.invalidateQueries(["user", "1"]);
await queryService.invalidateQueries(["user", "2"]);
await queryService.invalidateQueries(["user", "3"]);
// Invalidate all at once:
await queryService.invalidateQueries(["user"]); // Matches all user queriesTypeScript Support
Full type safety with automatic inference:
type User = { id: string; name: string; email: string };
type UserDelta = { modified: User[]; deleted: string[] };
const { data, sync } = useQuery({
key: ["users"],
get: async () => {
// Return type infers TData = User[]
const res = await fetch("/api/users");
return res.json() as User[];
},
sync: async (lastSync) => {
// Return type infers TDelta = UserDelta
const res = await fetch(`/api/users?since=${lastSync}`);
return res.json() as UserDelta;
},
merge: (delta, update) => {
// delta is typed as UserDelta ✅
// update receives Observable<User[]> ✅
update((usersObs) => {
// usersObs is Observable<User[]> ✅
delta.modified.forEach((user) => {
// user is typed as User ✅
});
});
},
onSync: (data) => {
// data is typed as User[] ✅
},
});
// data is User[] | null ✅
// sync is () => void (only if sync function provided) ✅Architecture
Event-Driven Design
All callbacks use events, preventing memory leaks:
Component mounts → registers event listeners
Query updates → emits events
Event listeners → handle callbacks
Component unmounts → disposes listeners ✅ No leaks!Cache Structure
{
queries: {
'["user","123"]': {
data: { id: '123', name: 'John' },
status: 'success',
lastUpdate: 1234567890,
lastSync: '2024-01-01T00:00:00Z',
isInvalidated: false,
offline: true
}
},
invalidatedKeys: ['["posts"]'],
metadata: {
version: '2.0.1',
lastUpdate: 1234567890
}
}Persistence Strategy
- Only
offline: truequeries are persisted onPersistreshapes a query's data on the way out, leaving memory untouched- The entire cache is serialized as a single JSON blob under one key (
${appName}-cache-next) — simple to implement on any storage backend - Debounced writes (500ms) reduce I/O
- Old queries (>7 days) automatically cleaned
- Version mismatch triggers cache reset
- Graceful fallback to memory-only mode if storage is unavailable
Migration Guide
Migrating from other query libraries is straightforward. Here are common patterns:
Basic Query
Before:
// Using queryKey and queryFn
const { data, refetch } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});After:
// Using key and get
const { data, invalidate } = useQuery({
key: ["todos"],
get: fetchTodos,
});Mutations
Before:
// Separate mutation hook
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});After:
// Direct mutation with helper
import { mutateInPlace } from "@mateosuarezdev/query";
const { error } = await mutateInPlace({
fn: () => updateTodo(data),
onSuccess: () => {
queryService.invalidateQueries(["todos"]);
},
});Optimistic Updates
Before:
// Complex context management
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
return { previous };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos"], context.previous);
},
});After:
// Direct observable updates
const { update, invalidate } = useQuery({
key: ["todos"],
get: fetchTodos,
});
async function addTodo(newTodo) {
// Optimistic update
update((todosObs) => todosObs.push(newTodo));
// Mutate
const { error } = await mutateInPlace({
fn: () => createTodo(newTodo),
});
// Refresh to confirm
if (!error) invalidate();
}Best Practices
- Use incremental sync for large datasets - Saves bandwidth and improves performance
- Define merge logic once - Store in shared utils, reuse across queries
- Enable offline selectively - Only persist data that makes sense offline
- Use pattern matching for related queries - Invalidate/sync groups efficiently
- Leverage observables - Direct mutations for fine-grained reactivity
- Set appropriate invalidation times - Balance freshness vs. network usage
- Handle loading states - Always check
statusbefore usingdata - Use type inference - Let TypeScript infer types from your functions
Troubleshooting
Query not updating
- Check if query key changed (creates new query)
- Verify
enabledistrue - Check network connectivity
- Look for invalidation conflicts
Sync not working
- Ensure
syncfunction returns proper delta format:{ modified: T[], deleted: string[] } - Check
mergefunction is defined (use built in helpers where possible) - Verify
lastSynctimestamp is being sent to server
Offline data not persisting
- Ensure
offline: trueis set (default) - On web: check IndexedDB browser support and verify not in private/incognito mode
- On React Native: ensure a
storeadapter was passed to the constructor - Check
queryService.isPersistenceEnabled— iffalse, checkqueryService.getPersistenceError
Performance issues
- Use more specific invalidation patterns
- Increase
invalidateAfterMinutesfor stable data - Consider disabling offline for large datasets
- Check for too many simultaneous queries
Contributing
This is part of my releasing tech series. Issues are welcome!
Credits
- Key hashing and pattern matching adapted from TanStack Query by Tanner Linsley
- Built on Legend State for reactive state management
License
README LICENSE.
