@mateosuarezdev/query
v1.0.15
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 = true;
NetInfo.fetch().then((s) => { isConnected = s.isConnected ?? true; });
const netAdapter: NetworkAdapter = {
isOnline: () => isConnected,
onOnline: (cb) =>
NetInfo.addEventListener((state) => {
const online = state.isConnected ?? true;
if (online && !isConnected) cb();
isConnected = online;
}),
};
// --- 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
Automatic IndexedDB persistence with smart batching:
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 operationPattern 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:
useQuery({
key: ["notifications"],
get: () => fetchNotifications(),
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; // Enable offline (default: true)
enabled?: boolean; // Enable/disable (default: true)
invalidateAfterMinutes?: number; // Auto-invalidate (default: 5)
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
}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>
</>
);
}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
});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 - 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.
