tanquery 0.8.0
tanquery: ^0.8.0 copied to clipboard
TanStack Query for Dart. Automatic caching, stale-while-revalidate, background refetching, mutations with optimistic updates, infinite queries. Pure Dart - no Flutter dependency.
tanquery #
TanStack Query for Dart. Stop writing the same fetch-cache-retry-loading-error boilerplate in every project. tanquery handles caching, background refetching, retries, and state management so you can focus on building features.
Pure Dart -- zero Flutter dependency. Works with Shelf, Dart Frog, CLI tools, or any Dart environment.
Table of Contents #
- The Problem
- Installation
- Quick Start (5 minutes)
- Core Concepts
- Queries
- Mutations
- Query Keys
- Stale Time & Caching
- Retry & Error Handling
- Advanced
- API Reference
- Architecture
- For Flutter
The Problem #
Every Dart app that fetches data ends up writing the same code:
// Without tanquery -- you write this in EVERY screen
class TodoService {
List<Todo>? _cache;
DateTime? _lastFetch;
bool _isLoading = false;
Object? _error;
int _retryCount = 0;
Future<List<Todo>> getTodos() async {
if (_cache != null && DateTime.now().difference(_lastFetch!) < Duration(minutes: 5)) {
return _cache!; // Return cached data
}
_isLoading = true;
try {
final todos = await api.fetchTodos();
_cache = todos;
_lastFetch = DateTime.now();
_retryCount = 0;
return todos;
} catch (e) {
_error = e;
if (_retryCount < 3) {
_retryCount++;
await Future.delayed(Duration(seconds: _retryCount * 2));
return getTodos(); // retry
}
rethrow;
} finally {
_isLoading = false;
}
}
}
// Then repeat for users, posts, comments, settings...
With tanquery, all of that becomes:
final todos = await client.fetchQuery<List<Todo>>(
queryKey: QueryKey(['todos']),
queryFn: () => api.fetchTodos(),
);
// Caching, retries, deduplication, staleness -- all handled automatically.
You get automatic caching, stale-while-revalidate, exponential backoff retries, request deduplication, and garbage collection. For free. On every query.
Installation #
dependencies:
tanquery: ^0.1.0
dart pub get
Flutter users: You probably want
tanquery_flutterinstead, which includes widget builders likeQueryBuilder. This package is the pure Dart core.
Quick Start (5 minutes) #
Step 1: Create a QueryClient #
The QueryClient is your central hub. Create one and mount it:
import 'package:tanquery/tanquery.dart';
final client = QueryClient();
client.mount(); // Starts listening for focus/connectivity changes
Step 2: Fetch data #
final todos = await client.fetchQuery<List<Todo>>(
queryKey: QueryKey(['todos']),
queryFn: () => api.fetchTodos(),
);
print(todos); // Your data, automatically cached
Step 3: Read from cache #
// Instant -- no network call
final cached = client.getQueryData<List<Todo>>(QueryKey(['todos']));
Step 4: Invalidate when data changes #
// Marks 'todos' as stale and triggers a background refetch
await client.invalidateQueries(queryKey: QueryKey(['todos']));
That's it. You now have a fully cached, auto-retrying data layer.
Core Concepts #
How Caching Works #
When you fetch with tanquery, this happens automatically:
1. First fetch --> Network request --> Cache result --> Return data
2. Second fetch --> Return cached data instantly (no network)
3. After staleTime --> Next access triggers background refetch
4. While refetching --> Show cached data + fetch silently in background
5. No observers --> Start GC timer --> Remove from cache after gcTime
This pattern is called stale-while-revalidate: your UI always has data to show, and it's always fresh (or refreshing).
Two-Axis State Model #
Every query has TWO independent state axes:
| Axis | Values | What it means |
|---|---|---|
| QueryStatus | pending, success, error |
Do we have data? |
| FetchStatus | fetching, paused, idle |
Is a network request happening? |
This lets you distinguish between:
- Loading (
pending+fetching): First load, no data yet, show spinner - Background refetch (
success+fetching): Have data, showing it, silently refreshing - Error with stale data (
error+idle): Fetch failed but we still have the old data
final result = observer.currentResult;
if (result.isLoading) print('First load...');
if (result.isFetching && result.isSuccess) print('Refreshing in background...');
if (result.isError && result.data != null) print('Error, but showing stale: ${result.data}');
Queries #
Basic Query #
final data = await client.fetchQuery<User>(
queryKey: QueryKey(['user', userId]),
queryFn: () => api.fetchUser(userId),
);
Reactive Query (with observer) #
For reactive updates (like in a UI), use QueryObserver:
final observer = QueryObserver<List<Todo>>(
cache: client.getQueryCache(),
queryKey: QueryKey(['todos']),
queryFn: () => api.fetchTodos(),
staleTime: Duration(minutes: 5),
);
// Subscribe to changes
final unsubscribe = observer.subscribe((result) {
print('Status: ${result.status}');
print('Data: ${result.data}');
print('Error: ${result.error}');
print('Is loading: ${result.isLoading}');
print('Is fetching: ${result.isFetching}');
});
// Later: clean up
unsubscribe();
Prefetching #
Fetch data before the user needs it:
// User is on the list page -- prefetch the detail page
await client.prefetchQuery<Todo>(
queryKey: QueryKey(['todo', nextTodoId]),
queryFn: () => api.fetchTodo(nextTodoId),
);
// When they navigate, data is already cached -- instant display
Dependent Queries #
Chain queries where one depends on another:
final userObserver = QueryObserver<User>(
cache: client.getQueryCache(),
queryKey: QueryKey(['user']),
queryFn: () => api.fetchUser(),
);
final todosObserver = QueryObserver<List<Todo>>(
cache: client.getQueryCache(),
queryKey: QueryKey(['todos', user?.id]),
queryFn: () => api.fetchTodosByUser(user!.id),
enabled: user != null, // Won't fetch until user data is available
);
Placeholder Data #
Show something while the real data loads:
final observer = QueryObserver<List<Todo>>(
cache: client.getQueryCache(),
queryKey: QueryKey(['todos']),
queryFn: () => api.fetchTodos(),
placeholderData: [], // Show empty list while loading
);
Or use previous query data for smooth transitions:
// When switching filters, show old filter's data while fetching new
final observer = QueryObserver<List<Todo>>(
cache: client.getQueryCache(),
queryKey: QueryKey(['todos', currentFilter]),
queryFn: () => api.fetchTodos(filter: currentFilter),
placeholderDataFn: (previousData, previousQuery) => previousData,
);
// result.isPlaceholderData tells you if showing old data
Select (Transform Data) #
Transform the raw response before it reaches your code:
final observer = QueryObserver<String>(
cache: client.getQueryCache(),
queryKey: QueryKey(['user']),
queryFn: () => api.fetchUser(), // returns User
select: (User user) => user.displayName, // transforms to String
);
// observer.currentResult.data is now a String, not a User
The select function is memoized -- it won't re-run if the raw data hasn't changed.
Mutations #
Mutations are for creating, updating, or deleting data on the server.
Basic Mutation #
final observer = MutationObserver<Todo, CreateTodoInput>(
cache: client.getMutationCache(),
config: MutationConfig(
mutationFn: (input) => api.createTodo(input),
),
);
// Fire and forget
observer.mutate(CreateTodoInput(title: 'Buy milk'));
// Or await the result
final newTodo = await observer.mutateAsync(CreateTodoInput(title: 'Buy milk'));
Mutation with Cache Invalidation #
After a mutation succeeds, invalidate related queries so they refetch:
final observer = MutationObserver<Todo, CreateTodoInput>(
cache: client.getMutationCache(),
config: MutationConfig(
mutationFn: (input) => api.createTodo(input),
onSuccess: (data, variables, context) async {
// This triggers all 'todos' queries to refetch
await client.invalidateQueries(queryKey: QueryKey(['todos']));
},
),
);
Optimistic Updates #
Update the UI before the server responds. Roll back if it fails:
final observer = MutationObserver<Todo, CreateTodoInput>(
cache: client.getMutationCache(),
config: MutationConfig(
mutationFn: (input) => api.createTodo(input),
onMutate: (input) async {
// Save current state for rollback
final previous = client.getQueryData<List<Todo>>(QueryKey(['todos']));
// Optimistically add the new todo
client.setQueryData<List<Todo>>(
QueryKey(['todos']),
(List<Todo> old) => [...old, Todo.fromInput(input)],
);
return previous; // This becomes 'context' in onError
},
onError: (error, variables, context) async {
// Roll back to previous state
if (context != null) {
client.setQueryData<List<Todo>>(QueryKey(['todos']), context);
}
},
onSettled: (data, error, variables, context) async {
// Always refetch to ensure consistency
await client.invalidateQueries(queryKey: QueryKey(['todos']));
},
),
);
Mutation Callback Order #
Callbacks fire in this exact order (matching TanStack Query):
1. onMutate(variables) -- optimistic update, return rollback data
2. mutationFn(variables) -- the actual API call
3. onSuccess/onError(...) -- handle result
4. onSettled(...) -- cleanup (fires on both success and error)
Query Keys #
Query keys are how tanquery identifies and organizes cached data.
Simple Keys #
QueryKey(['todos']) // All todos
QueryKey(['user']) // Current user
Parameterized Keys #
QueryKey(['todo', 42]) // Todo with id 42
QueryKey(['user', 'abc123']) // User with id abc123
Hierarchical Invalidation #
This is where query keys become powerful. Invalidation uses prefix matching:
// These are all in cache:
QueryKey(['todos'])
QueryKey(['todos', 1])
QueryKey(['todos', 2])
QueryKey(['todos', 'list', {'status': 'active'}])
// This ONE call invalidates ALL of the above:
await client.invalidateQueries(queryKey: QueryKey(['todos']));
// This only invalidates todos with id 1:
await client.invalidateQueries(queryKey: QueryKey(['todos', 1]), exact: true);
Think of it like a file system: invalidating a folder invalidates everything inside it.
Stale Time & Caching #
staleTime -- How long is data "fresh"? #
QueryObserver<User>(
// ...
staleTime: Duration(minutes: 5), // Data is fresh for 5 minutes
);
Duration.zero(default): Data is stale immediately. Every new subscriber triggers a refetch.Duration(minutes: 5): Data is fresh for 5 minutes. No refetches during that window.StaleTime.static_: Data is never stale. Only manual invalidation triggers refetch.
gcTime -- How long does unused data stay in memory? #
QueryObserver<User>(
// ...
gcTime: Duration(minutes: 30), // Keep in cache for 30 min after last subscriber leaves
);
Default: 5 minutes. After the last observer unsubscribes, the query stays in cache for gcTime before being garbage collected.
The Lifecycle #
Subscribe (fresh) --> Show cached data, no refetch
Subscribe (stale) --> Show cached data, refetch in background
Unsubscribe --> Start gcTime countdown
gcTime expires --> Remove from cache
New subscribe --> Full fetch (data was removed)
Retry & Error Handling #
Automatic Retries #
Queries retry 3 times by default with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 1 second |
| 2nd retry | 2 seconds |
| 3rd retry | 4 seconds |
| Max | 30 seconds (cap) |
// Customize retry behavior
QueryObserver<User>(
// ...
retryCount: 5, // retry up to 5 times
);
Mutations do NOT retry by default (retryCount: 0).
Network Modes #
QueryObserver<User>(
// ...
networkMode: NetworkMode.online, // Only fetch when online (default)
// networkMode: NetworkMode.always, // Fetch regardless of network
// networkMode: NetworkMode.offlineFirst, // Try once offline, pause for retry
);
Advanced #
Streamed Queries (WebSocket, SSE, LLM Streaming) #
For real-time data sources that send chunks over time:
final queryFn = streamedQuery<ChatMessage, List<ChatMessage>>(
streamFn: () => chatApi.messageStream(roomId),
reducer: (accumulated, chunk) => [...accumulated, chunk],
initialValue: [],
refetchMode: RefetchMode.append, // Keep existing messages, add new ones
onData: (messages) {
// Called after each chunk -- use for progressive UI updates
print('${messages.length} messages so far');
},
);
// Use it like any other query
final observer = QueryObserver<List<ChatMessage>>(
cache: client.getQueryCache(),
queryKey: QueryKey(['chat', roomId]),
queryFn: queryFn,
);
Refetch modes:
RefetchMode.reset(default): Clear existing data, start freshRefetchMode.append: Keep existing data, append new chunksRefetchMode.replace: Accumulate silently, swap all at once when stream ends
Hydration (Cache Persistence) #
Save the cache to disk and restore it on next app launch:
// Save cache (e.g., on app pause)
final state = dehydrate(client);
final json = state.toJson();
await prefs.setString('query_cache', jsonEncode(json));
// Restore cache (e.g., on app startup)
final savedJson = jsonDecode(prefs.getString('query_cache')!);
hydrate(client, DehydratedState.fromJson(savedJson));
Only successful queries are saved by default. Errors are redacted for security.
Request Deduplication #
Multiple subscribers requesting the same data at the same time get a single network request:
// These fire ONE network request, not three
final future1 = client.fetchQuery(queryKey: QueryKey(['todos']), queryFn: fetchTodos);
final future2 = client.fetchQuery(queryKey: QueryKey(['todos']), queryFn: fetchTodos);
final future3 = client.fetchQuery(queryKey: QueryKey(['todos']), queryFn: fetchTodos);
// All three resolve with the same data
Structural Sharing #
When data is refetched, tanquery preserves reference identity for unchanged parts:
// Old data: {'users': [User(1, 'Alice'), User(2, 'Bob')], 'count': 2}
// New data: {'users': [User(1, 'Alice'), User(2, 'Bobby')], 'count': 2}
// Result: 'count' keeps the old reference, 'users[0]' keeps the old reference
// Only 'users[1]' is a new object
This means identical(oldData.users[0], newData.users[0]) is true. Useful for avoiding unnecessary widget rebuilds in Flutter.
API Reference #
QueryClient #
| Method | Description |
|---|---|
mount() / unmount() |
Start/stop listening to focus and connectivity events |
fetchQuery(queryKey, queryFn) |
Fetch data (returns cached if fresh) |
prefetchQuery(queryKey, queryFn) |
Fetch in background, swallow errors |
ensureQueryData(queryKey, queryFn) |
Return cached or fetch if missing |
getQueryData(queryKey) |
Read cached data (synchronous, no fetch) |
setQueryData(queryKey, data) |
Write to cache manually |
invalidateQueries(queryKey) |
Mark as stale + trigger refetch |
refetchQueries(queryKey) |
Force refetch regardless of staleness |
cancelQueries(queryKey) |
Cancel in-flight fetches |
removeQueries(queryKey) |
Remove from cache entirely |
resetQueries(queryKey) |
Reset to initial state |
clear() |
Clear all caches |
isFetching() |
Count of currently fetching queries |
isMutating() |
Count of currently pending mutations |
QueryObserverResult #
| Property | Type | Description |
|---|---|---|
data |
T? |
The cached data (null if not yet fetched) |
error |
Object? |
The error if fetch failed |
status |
QueryStatus |
pending, success, or error |
fetchStatus |
FetchStatus |
fetching, paused, or idle |
isLoading |
bool |
First load (no data + fetching) |
isFetching |
bool |
Any network activity (including background) |
isSuccess |
bool |
Data available |
isError |
bool |
Fetch failed |
isStale |
bool |
Data is past staleTime |
isPlaceholderData |
bool |
Showing placeholder, not real data |
isFetched |
bool |
At least one fetch has completed |
isRefetching |
bool |
Refetching with existing data |
isLoadingError |
bool |
Error with no data at all |
isRefetchError |
bool |
Error but still have stale data |
dataUpdatedAt |
DateTime? |
When data was last updated |
Architecture #
Faithfully follows TanStack Query's internal architecture, analyzed from all 8,698 lines of TanStack source code:
QueryClient (public API -- the only class you interact with)
|
+-- QueryCache (stores Query instances by key hash)
| +-- Query (state machine with 8-action reducer)
| | +-- Retryer (exponential backoff, pause/continue/cancel)
| +-- QueryObserver (bridges Query state to your UI/code)
|
+-- MutationCache (scoped sequential execution)
| +-- Mutation (state machine with exact callback ordering)
| +-- MutationObserver (manages mutation lifecycle)
|
+-- FocusManager (app visibility -- triggers refetch on focus)
+-- OnlineManager (network connectivity -- pauses/resumes fetches)
+-- NotifyManager (batches notifications to prevent thrashing)
For Flutter #
For Flutter widget builders (QueryBuilder, MutationBuilder, InfiniteQueryBuilder) and the visual devtools overlay, see:
tanquery_flutter-- Widget builderstanquery_devtools-- Visual cache inspector
Comparison: Before & After #
| Without tanquery | With tanquery |
|---|---|
| Manual cache Map per service | Automatic cache with configurable TTL |
| Custom retry logic everywhere | Built-in exponential backoff (1s-30s) |
| No request deduplication | Same key = one network call |
| Manual loading/error states | Two-axis state model (status + fetchStatus) |
| No background refetch | Auto-refetch on focus, reconnect, interval |
| No cache invalidation | Hierarchical key invalidation |
| No garbage collection | Automatic GC with configurable gcTime |
| ~50 lines per data source | ~5 lines per data source |
License #
MIT