tanquery_flutter 0.8.0
tanquery_flutter: ^0.8.0 copied to clipboard
Flutter adapter for tanquery. Widget builders for automatic caching, stale-while-revalidate, and background refetching. QueryBuilder, MutationBuilder, InfiniteQueryBuilder.
tanquery_flutter #
Flutter adapter for tanquery. Drop-in widget builders that replace your FutureBuilder + loading + error + retry boilerplate with one widget.
Table of Contents #
- The Problem
- Installation
- Quick Start (3 minutes)
- QueryBuilder
- MutationBuilder
- InfiniteQueryBuilder
- QueriesBuilder
- Common Patterns
- API Reference
The Problem #
Every Flutter screen that loads data looks like this:
// Without tanquery_flutter -- repeated on every screen
class TodoScreen extends StatefulWidget { ... }
class _TodoScreenState extends State<TodoScreen> {
late Future<List<Todo>> _future;
bool _isLoading = true;
Object? _error;
List<Todo>? _data;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() { _isLoading = true; _error = null; });
try {
final data = await api.fetchTodos();
setState(() { _data = data; _isLoading = false; });
} catch (e) {
setState(() { _error = e; _isLoading = false; });
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) return CircularProgressIndicator();
if (_error != null) return Text('Error: $_error');
return ListView(children: _data!.map(TodoTile.new).toList());
}
}
With tanquery_flutter:
// That entire class becomes:
QueryBuilder<List<Todo>>(
queryKey: QueryKey(['todos']),
queryFn: () => api.fetchTodos(),
builder: (context, state) {
if (state.isLoading) return CircularProgressIndicator();
if (state.isError) return Text('Error: ${state.error}');
return ListView(children: state.data!.map(TodoTile.new).toList());
},
)
And you get caching, retries, background refetching, and deduplication for free.
Installation #
dependencies:
tanquery_flutter: ^0.1.0
flutter pub get
This package re-exports tanquery, so you only need this one import:
import 'package:tanquery_flutter/tanquery_flutter.dart';
Quick Start (3 minutes) #
Step 1: Wrap your app with DartQueryProvider #
import 'package:tanquery_flutter/tanquery_flutter.dart';
void main() {
final queryClient = QueryClient();
runApp(
DartQueryProvider(
client: queryClient,
child: MaterialApp(home: HomeScreen()),
),
);
}
DartQueryProvider automatically:
- Mounts the
QueryClientlifecycle - Detects app focus changes (refetch when user returns to app)
- Handles disposal on unmount
Step 2: Use QueryBuilder anywhere in your widget tree #
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Todos')),
body: QueryBuilder<List<Todo>>(
queryKey: QueryKey(['todos']),
queryFn: () => api.fetchTodos(),
staleTime: Duration(minutes: 5),
builder: (context, state) {
if (state.isLoading) return Center(child: CircularProgressIndicator());
if (state.isError) return Center(child: Text('Error: ${state.error}'));
return ListView.builder(
itemCount: state.data!.length,
itemBuilder: (_, i) => ListTile(title: Text(state.data![i].title)),
);
},
),
);
}
}
That's it. Navigate away and back -- cached data shows instantly. Pull to refresh -- background refetch updates the list. Network error -- automatic retry with backoff.
QueryBuilder #
The core widget. Wraps a QueryObserver and rebuilds when data changes.
QueryBuilder<T>(
queryKey: QueryKey(['key']), // Required: unique cache identifier
queryFn: () => fetchData(), // Required: the async function to call
builder: (context, state) { ... }, // Required: build your UI from state
// Optional:
staleTime: Duration(minutes: 5), // How long data is "fresh" (default: 0)
gcTime: Duration(minutes: 30), // How long unused data stays cached (default: 5min)
enabled: true, // Set false to pause fetching
retryCount: 3, // Retry attempts on failure (default: 3)
placeholderData: [], // Show while loading
select: (data) => data.length, // Transform data before it reaches builder
refetchInterval: Duration(seconds: 30), // Auto-refetch on interval
)
Understanding the State Object #
The state parameter in the builder gives you everything:
builder: (context, state) {
// Loading states
state.isLoading // First load, no data yet -- show spinner
state.isFetching // Any network activity (including background refresh)
state.isRefetching // Has data AND is refetching (background refresh)
// Data states
state.isSuccess // Data available
state.data // The actual data (T?)
state.isPending // No data yet (before first fetch completes)
// Error states
state.isError // Fetch failed
state.error // The error object
state.isLoadingError // Error with no data (first load failed)
state.isRefetchError // Error but stale data still available
// Meta
state.isStale // Past staleTime
state.isPlaceholderData // Showing placeholder, not real data
state.isFetched // At least one fetch completed
state.dataUpdatedAt // When data was last updated
}
Common Builder Patterns #
Simple loading/error/data:
builder: (context, state) {
if (state.isLoading) return CircularProgressIndicator();
if (state.isError) return Text('Error: ${state.error}');
return DataWidget(data: state.data!);
}
Show stale data during refetch:
builder: (context, state) {
if (state.isLoading) return CircularProgressIndicator();
return Column(
children: [
if (state.isFetching) LinearProgressIndicator(), // subtle refresh indicator
DataWidget(data: state.data!),
if (state.isRefetchError)
Text('Failed to refresh, showing cached data'),
],
);
}
Pull to refresh:
builder: (context, state) {
if (state.isLoading) return CircularProgressIndicator();
return RefreshIndicator(
onRefresh: () async {
DartQuery.of(context).invalidateQueries(queryKey: QueryKey(['todos']));
},
child: ListView(...),
);
}
MutationBuilder #
For creating, updating, or deleting data. Gives you a mutate function and tracks the mutation state.
MutationBuilder<Todo, CreateTodoInput>(
mutationFn: (input) => api.createTodo(input),
onSuccess: (data, input, context) async {
// Invalidate related queries after successful mutation
DartQuery.of(context).invalidateQueries(queryKey: QueryKey(['todos']));
},
builder: (context, state, mutate, mutateAsync) {
return Column(
children: [
ElevatedButton(
onPressed: state.isPending
? null // Disable while in progress
: () => mutate(CreateTodoInput(title: 'New todo')),
child: Text(state.isPending ? 'Saving...' : 'Add Todo'),
),
if (state.isError) Text('Failed: ${state.error}', style: TextStyle(color: Colors.red)),
if (state.isSuccess) Text('Created: ${state.data!.title}', style: TextStyle(color: Colors.green)),
],
);
},
)
mutate vs mutateAsync:
mutate(input)-- Fire and forget. Errors are handled viaonErrorcallback.mutateAsync(input)-- Returns aFuture. You canawaitit and handle errors with try/catch.
InfiniteQueryBuilder #
For paginated lists and infinite scroll. Manages page params and exposes fetchNextPage / fetchPreviousPage.
InfiniteQueryBuilder<List<Todo>, int>(
queryKey: QueryKey(['todos', 'infinite']),
queryFn: (pageParam) => api.fetchTodos(page: pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages, lastParam, allParams) {
// Return null when there are no more pages
return lastPage.length == 20 ? lastParam + 1 : null;
},
builder: (context, state, fetchNextPage, fetchPreviousPage) {
if (state.isLoading) return Center(child: CircularProgressIndicator());
final allItems = state.data?.pages.expand((page) => page).toList() ?? [];
return NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
// Auto-fetch next page when near bottom
if (scrollInfo.metrics.extentAfter < 200) {
fetchNextPage();
}
return false;
},
child: ListView.builder(
itemCount: allItems.length,
itemBuilder: (_, i) => ListTile(title: Text(allItems[i].title)),
),
);
},
)
Cursor-based pagination:
InfiniteQueryBuilder<List<Post>, String>(
queryKey: QueryKey(['posts']),
queryFn: (cursor) => api.fetchPosts(cursor: cursor),
initialPageParam: '',
getNextPageParam: (lastPage, allPages, lastCursor, allCursors) {
return lastPage.isNotEmpty ? lastPage.last.cursor : null;
},
builder: (context, state, fetchNextPage, _) { ... },
)
QueriesBuilder #
Fetch multiple queries in parallel with a single widget:
QueriesBuilder(
queries: [
QueryConfig(key: QueryKey(['user']), fn: () => api.fetchUser()),
QueryConfig(key: QueryKey(['todos']), fn: () => api.fetchTodos()),
QueryConfig(key: QueryKey(['settings']), fn: () => api.fetchSettings()),
],
builder: (context, results) {
final userState = results[0];
final todosState = results[1];
final settingsState = results[2];
if (results.any((r) => r.isLoading)) {
return CircularProgressIndicator();
}
return Dashboard(
user: userState.data,
todos: todosState.data,
settings: settingsState.data,
);
},
)
Common Patterns #
Access the Client Anywhere #
final client = DartQuery.of(context);
// Invalidate queries (triggers refetch)
client.invalidateQueries(queryKey: QueryKey(['todos']));
// Update cached data directly
client.setQueryData<User>(QueryKey(['user']), updatedUser);
// Prefetch data for the next screen
client.prefetchQuery(queryKey: QueryKey(['todo', id]), queryFn: () => api.fetchTodo(id));
Conditional Fetching #
QueryBuilder<UserProfile>(
queryKey: QueryKey(['profile', userId]),
queryFn: () => api.fetchProfile(userId!),
enabled: userId != null, // Won't fetch until userId is set
builder: (context, state) { ... },
)
Refetch Interval (Polling) #
QueryBuilder<StockPrice>(
queryKey: QueryKey(['stock', ticker]),
queryFn: () => api.fetchPrice(ticker),
refetchInterval: Duration(seconds: 5), // Auto-refetch every 5 seconds
builder: (context, state) {
return Text('\$${state.data?.price ?? '---'}');
},
)
API Reference #
DartQueryProvider #
| Property | Type | Description |
|---|---|---|
client |
QueryClient |
Required. The query client instance. |
child |
Widget |
Required. Your app widget tree. |
DartQuery.of(context) #
Returns the QueryClient from the nearest DartQueryProvider. Throws if no provider found.
QueryBuilder #
| Property | Type | Default | Description |
|---|---|---|---|
queryKey |
QueryKey |
Required | Cache key |
queryFn |
Future<T> Function() |
Required | Fetch function |
builder |
Widget Function(context, state) |
Required | Build UI from state |
staleTime |
Duration |
Duration.zero |
Freshness window |
gcTime |
Duration |
5 minutes |
Cache retention after unmount |
enabled |
bool |
true |
Enable/disable fetching |
retryCount |
int |
3 |
Retry attempts |
placeholderData |
T? |
null |
Show while loading |
select |
T Function(T)? |
null |
Transform data |
refetchInterval |
Duration? |
null |
Auto-refetch interval |
DevTools #
Add visual cache inspection to your app:
DartQueryProvider(
client: queryClient,
child: DartQueryDevtools(
enabled: kDebugMode,
child: MaterialApp(...),
),
);
See tanquery_devtools for details.
License #
MIT