flutter_query_client
A TanStack Query-inspired server state management library for Flutter.
Handles fetching, caching, synchronizing, and updating server state with minimal boilerplate — automatic background refetching, stale-while-revalidate caching, infinite pagination, mutation support, and a rich widget layer out of the box.
Features
- QueryController — fetch and cache server data automatically
- MutationController — user-triggered mutations; emits
QueryStateso all Query* widgets work with it - InfiniteQueryController — paginated / infinite-scroll data with
loadMore() - Stale-while-revalidate — serve cached data instantly, refetch in background
- Configurable stale time & garbage collection — control cache lifetime precisely
- Automatic retry with exponential backoff — resilient to transient failures
- Network-aware fetching — pauses when offline, resumes on reconnect
- Refetch-on-visible — screens automatically refetch when you return to them (tab switch and
Navigatorpush/pop), driven by the builder widgets — see Refetch on Visible keepPreviousData— show old results while fetching new ones aftersetParams()- Global defaults with per-controller overrides
- BLoC-based state — integrates naturally with
flutter_bloc; observe all controllers viaQueryObserverset inQueryClientProvider - Freezed immutable state — safe pattern matching on
QueryState
Installation
dependencies:
flutter_query_client: ^4.1.0
Quick Start
1. Wrap your app
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return QueryClientProvider(
defaults: const QueryDefaults(
staleTime: Duration(minutes: 5),
gcTime: Duration(minutes: 10),
retryCount: 3,
),
// Optional: attach a global observer (no flutter_bloc import needed).
observer: AppQueryObserver(),
child: const MaterialApp(home: HomeScreen()),
);
}
}
2. Define a controller
class PostsController extends QueryController<List<Post>, void> {
PostsController() : super('posts');
@override
Future<List<Post>> queryFn(void params) => postService.getPosts();
}
3. Provide and build
QueryProvider<PostsController, List<Post>>(
create: (_) => PostsController(),
child: QueryBuilder<PostsController, List<Post>>(
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
if (state.isError) return Text('Error: ${state.error}');
return PostListView(posts: state.data!);
},
),
),
Core Concepts
QueryState
Every controller emits QueryState<T>:
| Property | Type | Description |
|---|---|---|
data |
T? |
The cached data |
error |
Object? |
The last error |
status |
QueryStatus |
idle · loading · success · error |
fetchStatus |
FetchStatus |
idle · fetching · refetching · paused |
isStale |
bool |
Data is past its stale time |
isPlaceholderData |
bool |
Showing previous data during a setParams() fetch |
isLoadingMore |
bool |
Infinite query is fetching the next page |
isLoading |
bool |
status == loading |
isSuccess |
bool |
status == success |
isError |
bool |
status == error |
isIdle |
bool |
status == idle |
isFetching |
bool |
Any active background fetch |
isRefetching |
bool |
Background refetch with data already present |
isPaused |
bool |
Fetch paused (offline) |
hasData |
bool |
data != null |
hasError |
bool |
error != null |
Widget Reference
Provider Widgets
| Widget | Purpose |
|---|---|
QueryProvider<C, T> |
Provides a QueryController or MutationController via BlocProvider; handles remount |
InfiniteQueryProvider<C> |
Provides an InfiniteQueryController; handles remount |
MultiQueryProvider |
Nest multiple providers without indentation |
QueryClientProvider |
Inject QueryClient + global QueryDefaults into the tree |
Builder / Listener Widgets
| Widget | Purpose |
|---|---|
QueryBuilder<C, T> |
Rebuild on QueryState<T> changes |
InfiniteQueryBuilder<C, T> |
Rebuild on QueryState<List<T>> changes |
QueryListener<C, T> |
Side-effects on QueryState<T> changes — no rebuild |
InfiniteQueryListener<C, T> |
Side-effects on QueryState<List<T>> — no List<T> in type arg |
MultiQueryListener |
Multiple QueryListeners without extra widget layers |
Consumer Widgets (builder + listener combined)
| Widget | Purpose |
|---|---|
QueryConsumer<C, T> |
QueryBuilder + QueryListener in one widget |
InfiniteQueryConsumer<C, T> |
Same for infinite queries; List<T> baked in |
Selector Widgets (minimal rebuilds)
| Widget | Purpose |
|---|---|
QuerySelector<C, T, S> |
Rebuilds only when a derived value S changes |
InfiniteQuerySelector<C, T, S> |
Same for infinite queries; List<T> baked in |
Tip — mutations use the same widgets.
MutationController<T>emitsQueryState<T>, soQueryBuilder,QueryListener,QueryConsumer, andQuerySelectorall work with mutations out of the box.
Provider Widgets
QueryProvider
QueryProvider<PostsController, List<Post>>(
create: (_) => PostsController(),
child: const PostsListView(),
)
Automatically calls controller.handleRemount() when a widget transitions from hidden → visible (e.g. inside IndexedStack or Visibility).
InfiniteQueryProvider
InfiniteQueryProvider<ProductsController>(
create: (_) => ProductsController(),
child: const ProductsView(),
)
MultiQueryProvider
Provide multiple controllers without deep nesting — identical ergonomics to MultiBlocProvider. Pass each provider without a child; MultiQueryProvider injects it automatically. Providers are applied top-to-bottom (first entry = outermost ancestor).
MultiQueryProvider(
providers: [
QueryProvider<PostsController, List<Post>>(
create: (_) => PostsController(),
),
QueryProvider<CreatePostMutation, Post>(
create: (_) => CreatePostMutation(),
),
InfiniteQueryProvider<ProductsController>(
create: (_) => ProductsController(),
),
],
child: const HomeScreen(),
)
Builder Widgets
QueryBuilder
QueryBuilder<PostsController, List<Post>>(
buildWhen: (prev, next) => prev.status != next.status,
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
if (state.isError) return ErrorView(error: state.error);
if (state.isRefetching) return PostsView(posts: state.data!, loading: true);
return PostsView(posts: state.data ?? []);
},
)
InfiniteQueryBuilder
InfiniteQueryBuilder<ProductsController, Product>(
builder: (context, state) {
final products = state.data ?? [];
return ListView.builder(
itemCount: products.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (_, i) {
if (i >= products.length) return const LoadingTile();
return ProductTile(products[i]);
},
);
},
)
Listener Widgets
QueryListener
Side-effects only — no rebuild. Works with both query and mutation controllers.
QueryListener<PostsController, List<Post>>(
listenWhen: (prev, next) => !prev.isError && next.isError,
listener: (context, state) => showErrorSnackBar(state.error),
child: const PostsView(),
)
// With a MutationController
QueryListener<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess || next.isError,
listener: (context, state) {
if (state.isSuccess) Navigator.pop(context, state.data);
if (state.isError) showErrorSnackBar(state.error);
},
child: const CreatePostForm(),
)
InfiniteQueryListener
Identical to QueryListener for infinite queries — the List<T> wrapper is baked into the type so you only specify the item type:
// Before (verbose)
QueryListener<ProductsController, List<Product>>(...)
// After
InfiniteQueryListener<ProductsController, Product>(
listenWhen: (prev, next) => prev.isLoadingMore && !next.isLoadingMore,
listener: (context, state) {
if (state.isError) showErrorSnackBar('Failed to load more');
},
child: const ProductsView(),
)
MultiQueryListener
Attach multiple listeners without extra widget layers:
MultiQueryListener(
listeners: [
QueryListener<PostsController, List<Post>>(
listenWhen: (_, next) => next.isError,
listener: (context, state) => showErrorSnackBar(state.error),
),
QueryListener<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess || next.isError,
listener: (context, state) { /* handle mutation result */ },
),
],
child: const HomeScreen(),
)
Consumer Widgets
Combine builder + listener without nesting — useful when you need both a reactive UI and a side-effect on the same controller.
QueryConsumer
QueryConsumer<PostsController, List<Post>>(
listenWhen: (_, next) => next.isError,
listener: (context, state) => showErrorSnackBar(state.error),
buildWhen: (prev, next) => prev.data != next.data,
builder: (context, state) {
if (state.isLoading) return const CircularProgressIndicator();
return PostsView(posts: state.data ?? []);
},
)
// With a MutationController
QueryConsumer<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess,
listener: (context, state) => Navigator.pop(context, state.data),
builder: (context, state) => FilledButton(
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CircularProgressIndicator()
: const Text('Submit'),
),
)
InfiniteQueryConsumer
InfiniteQueryConsumer<ProductsController, Product>(
listenWhen: (prev, next) => prev.isLoadingMore && !next.isLoadingMore && next.isError,
listener: (context, state) => showErrorSnackBar('Failed to load more'),
builder: (context, state) {
final products = state.data ?? [];
if (state.isLoading) return const CircularProgressIndicator();
return ProductsView(products: products, isLoadingMore: state.isLoadingMore);
},
)
Selector Widgets
Rebuilds only when the selected value changes — use for badges, counters, or flags that don't need the full state.
QuerySelector
// Only rebuilds when post count changes — ignores refetch/stale events.
QuerySelector<PostsController, List<Post>, int>(
selector: (state) => state.data?.length ?? 0,
builder: (context, count) => Badge(label: Text('$count')),
)
// Only rebuilds when the mutation's loading flag toggles.
QuerySelector<CreatePostMutation, Post, bool>(
selector: (state) => state.isLoading,
builder: (context, isLoading) => FilledButton(
onPressed: isLoading ? null : _submit,
child: const Text('Submit'),
),
)
InfiniteQuerySelector
// Only rebuilds when total item count changes.
InfiniteQuerySelector<ProductsController, Product, int>(
selector: (state) => state.data?.length ?? 0,
builder: (context, count) => Text('$count items loaded'),
)
QueryController
Base class for any single-resource fetch.
class PostByIdController extends QueryController<Post, int> {
PostByIdController() : super('post');
// Optional overrides
@override Duration? get staleTime => const Duration(seconds: 30);
@override int get retryCount => 1;
@override RefetchOnMount get refetchOnMount => RefetchOnMount.always;
@override
Future<Post> queryFn(int? id) => postService.getPostById(id!);
// Lifecycle hooks
@override void onSuccess(Post data) { /* analytics, logging */ }
@override void onQueryError(Object error) { /* report error */ }
}
Key methods
// Trigger the query (or re-trigger with new params)
controller.setParams(postId);
// Manual refetch
controller.refetch();
// Get or fetch data imperatively (returns cached if fresh)
final post = await controller.ensureData(postId);
// Optimistic cache update
controller.updateCache((current) => current?.copyWith(title: 'New title'));
// Invalidate — marks stale and refetches if active
controller.invalidate();
Override contract
| Override | Default | Purpose |
|---|---|---|
queryFn(P? params) |
— | Required. The fetch function |
enabled |
auto | false prevents execution (auto: false when P ≠ void and params == null) |
staleTime |
null |
Duration until data is considered stale (null = never) |
refetchInterval |
null |
Auto-poll interval (null = disabled) |
refetchOnMount |
always |
always · stale · never |
refetchOnReconnect |
ifStale |
always · ifStale · never |
networkMode |
online |
online · always · offlineFirst |
retryCount |
3 |
Retry attempts on failure |
retryDelay |
1s |
Base delay; actual uses exponential backoff |
keepPreviousData |
false |
Show old data during setParams() refetch |
onSuccess(T data) |
no-op | Called after successful fetch |
onQueryError(Object e) |
no-op | Called after all retries fail |
InfiniteQueryController
For cursor- or page-based pagination. T = item type, PageParam = page parameter type, P = filter type (void if no filters).
initialPageParam and limit are optional — they inherit from QueryDefaults (0 and 20 by default) and only need to be overridden when a controller differs from the global setting.
// Minimal — initialPageParam and limit come from QueryDefaults.
class ProductsController extends InfiniteQueryController<Product, int, String> {
ProductsController() : super('products');
@override bool get keepPreviousData => true;
@override
int? getNextPageParam(List<Product> lastPage, List<List<Product>> allPages) =>
lastPage.length < limit ? null : allPages.length;
@override
Future<List<Product>> queryFn(int pageParam, String? query) =>
productService.getProducts(page: pageParam, search: query ?? '');
}
// Override only when this controller differs from the global default.
class CursorProductsController extends InfiniteQueryController<Product, String, void> {
CursorProductsController() : super('cursor-products');
@override String get initialPageParam => ''; // cursor, not an int
@override int get limit => 10; // smaller page size
@override
String? getNextPageParam(List<Product> last, List<List<Product>> all) =>
last.isEmpty ? null : last.last.cursor;
@override
Future<List<Product>> queryFn(String cursor, void _) =>
productService.getProductsAfter(cursor, limit: limit);
}
Key methods
// Trigger next page
await controller.loadMore();
// Apply new filter params — resets pagination and refetches
controller.setParams('running shoes');
// Refetch from page 0
controller.refetch();
// Invalidate and reset
controller.invalidateAndRefresh();
// Optimistic item updates across all pages
controller.updateItem((p) => p.id == updated.id, updated);
controller.removeItem((p) => p.id == deletedId);
controller.prependItem(newItem);
controller.appendItem(newItem);
Key getters
controller.hasMore // bool — whether getNextPageParam returns non-null
controller.currentPage // int — number of pages loaded
controller.totalItems // int — flat item count across all pages
controller.filters // P? — current filter params
MutationController
For create / update / delete operations. Takes a typed params generic P matching QueryController<T, P>. Override mutationFn(P params) — no closures needed. Mutations emit QueryState<T>, so every Query* widget works with them out of the box.
// Define a record typedef for the params (Dart 3 named records work well).
typedef CreatePostParams = ({int userId, String title, String body});
class CreatePostMutation extends MutationController<Post, CreatePostParams> {
@override
Future<Post> mutationFn(CreatePostParams params) {
return postService.createPost(
userId: params.userId,
title: params.title,
body: params.body,
);
}
@override
void onSuccess(Post data) => QueryLogger.info('Created: ${data.id}');
@override
void onMutationError(Object error) => QueryLogger.warning('Failed: $error');
@override
void onSettled(Post? data, Object? error) { /* always called */ }
}
// For mutations that need no params, use void as the second type argument.
class DeletePostMutation extends MutationController<bool, int> {
@override
Future<bool> mutationFn(int id) async {
await postService.deletePost(id);
return true;
}
}
// Provide like a QueryController
QueryProvider<CreatePostMutation, Post>(
create: (_) => CreatePostMutation(),
child: const CreatePostForm(),
)
// Trigger with typed named-record params
context.query<CreatePostMutation>().mutate((
userId: 1,
title: 'Hello',
body: 'World',
));
// Reset state to idle
context.query<CreatePostMutation>().reset();
// Use standard query widgets — mutations emit QueryState<T>
QueryConsumer<CreatePostMutation, Post>(
listenWhen: (_, next) => next.isSuccess || next.isError,
listener: (context, state) { /* navigate / show toast */ },
builder: (context, state) => FilledButton(
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CircularProgressIndicator()
: const Text('Create'),
),
)
Override contract
| Override | Default | Purpose |
|---|---|---|
mutationFn(P params) |
— | Required. The mutation function; receives typed params |
retryCount |
0 |
Global retryCount intentionally not applied to mutations |
onSuccess(T data) |
no-op | Called after mutation succeeds |
onMutationError(Object e) |
no-op | Called after mutation fails |
onSettled(T? data, Object? error) |
no-op | Always called after settle |
QueryClient
Singleton cache manager. Access via QueryClient.instance or context.queryClient.
final client = QueryClient.instance;
// Invalidate a specific key + params (removes from cache)
client.invalidate('posts', serializedParams);
// Invalidate all param variants for a key and notify active controllers
client.invalidateQueries(['posts', 'products']);
// Update cached data for all param variants of a key
client.update<List<Post>>('posts', (posts) => [newPost, ...posts]);
// Update infinite query pages
client.updateInfiniteQuery<Product>('products', (pages) => pages);
// Direct cache access
final cached = client.get<List<Post>>('posts');
// Register a global observer (prefer QueryClientProvider(observer:) at startup)
client.setObserver(AppQueryObserver());
// Set global defaults
client.setDefaults(const QueryDefaults(staleTime: Duration(minutes: 5)));
BuildContext extensions
// Get a controller from the widget tree
final ctrl = context.query<PostsController>();
// Get the QueryClient
final client = context.queryClient;
// Read cached data directly
final posts = context.cachedQuery<List<Post>>('posts');
final post = context.cachedQueryWithParams<Post>('post', serializedId);
QueryObserver
A BlocObserver subclass that filters events to query and mutation controllers only, exposing typed hooks. Pass it to QueryClientProvider alongside your other global defaults — no direct flutter_bloc import needed in your app.
QueryClientProvider(
defaults: const QueryDefaults(...),
observer: AppQueryObserver(), // ← registered here, alongside defaults
child: const MyApp(),
)
class AppQueryObserver extends QueryObserver {
/// Called when any QueryController, InfiniteQueryController,
/// or MutationController is created.
@override
void onQueryCreate(String cacheKey) =>
debugPrint('created: $cacheKey');
/// currentState + nextState — same semantics as BlocObserver.onChange.
@override
void onQueryChange(
String cacheKey,
QueryState<dynamic> currentState,
QueryState<dynamic> nextState,
) {
debugPrint(
'[$cacheKey] ${currentState.status.name} → ${nextState.status.name}',
);
}
@override
void onQueryError(String cacheKey, Object error, StackTrace stackTrace) =>
FirebaseCrashlytics.instance.recordError(error, stackTrace);
@override
void onQueryClose(String cacheKey) =>
debugPrint('closed: $cacheKey');
}
To also observe non-query cubits, override the raw BlocObserver hooks and call super:
@override
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
super.onChange(bloc, change); // ← fires onQueryChange for query controllers
if (bloc is MyOtherCubit) { /* handle */ }
}
Global Configuration
All global setup lives in a single QueryClientProvider:
QueryClientProvider(
client: QueryClient.instance, // optional — defaults to singleton
observer: AppQueryObserver(), // optional — global lifecycle observer
defaults: QueryDefaults(
staleTime: const Duration(minutes: 5),
gcTime: const Duration(minutes: 10),
retryCount: 3,
retryDelay: const Duration(seconds: 1),
refetchOnMount: RefetchOnMount.stale,
refetchOnReconnect: RefetchOnReconnect.ifStale,
networkMode: NetworkMode.online,
refetchInterval: null,
// Infinite query defaults — applied to every InfiniteQueryController
// that doesn't override them individually.
initialPageParam: 0, // first page param (default: 0)
limit: 20, // items per page (default: 20)
transformError: (error) => AppException.from(error),
connectivityEndpoints: [
InternetCheckOption(uri: Uri.parse('https://api.example.com/health')),
],
enableLogging: true,
logLevel: Level.INFO,
),
child: const MyApp(),
)
Precedence
Every option resolves controller override → global QueryDefaults → hardcoded
default. A value you override on a controller always wins over the global
default; the global applies only when the controller doesn't override it; the
built-in default is the final fallback. This holds for refetchOnMount,
staleTime, gcTime, retryCount, retryDelay, refetchInterval,
networkMode, refetchOnReconnect, refetchOnAppFocus,
refetchIntervalInBackground, and keepPreviousData — on every controller type.
QueryDefaults(refetchOnMount: RefetchOnMount.never) // global
class Feed extends QueryController<List<Post>, void> {
@override
RefetchOnMount get refetchOnMount => RefetchOnMount.always; // wins → Feed refetches
}
Fixed in 4.1.0 — earlier versions let a non-null global default override an explicit controller override.
gcTimeis also per-controller overridable as of 4.1.0.
Error Handling
Errors thrown by your queryFn or mutationFn flow through unchanged — even after retry exhaustion. transformError, lifecycle hooks (onQueryError, onMutationError), and state.error all receive the original error, not a framework wrapper.
// Your API client throws a typed error:
class ApiException implements Exception {
final int statusCode;
final String message;
ApiException(this.statusCode, this.message);
}
// transformError receives the original ApiException — not a QueryException.
QueryClientProvider(
defaults: QueryDefaults(
retryCount: 3,
transformError: (error) {
if (error is ApiException) {
return 'Server error ${error.statusCode}: ${error.message}';
}
return error;
},
),
child: const MyApp(),
)
In the UI, state.error contains whatever transformError returns (or the raw error if no transform is set):
QueryBuilder<PostsController, List<Post>>(
builder: (context, state) {
if (state.isError) return Text('${state.error}');
// ...
},
)
Per-controller transformError overrides the global one:
class PostsController extends QueryController<List<Post>, void> {
PostsController()
: super('posts', transformError: (error) {
if (error is ApiException && error.statusCode == 404) {
return 'Posts not found';
}
return error;
});
// ...
}
Network Modes
| Mode | Behaviour |
|---|---|
NetworkMode.online |
Pauses fetching when offline, resumes on reconnect |
NetworkMode.always |
Fetches regardless of network status |
NetworkMode.offlineFirst |
Executes once without network check, then requires connectivity |
Refetch on Visible (navigation)
A query refetches automatically when its screen becomes visible again — so
returning to a screen shows fresh data — matching TanStack Query's mount/focus
behaviour. Because Flutter (unlike React) keeps screens mounted when they're
covered or tabbed away, this is driven from the rendering widgets
(QueryBuilder / QueryConsumer / QuerySelector and their infinite/multi
variants), not the provider. That means it works even when the controller is
provided at the app root, and it composes across multiple screens.
Two visibility signals are detected, covering the full range of navigation:
| You do this | Detected via |
|---|---|
Switch a bottom-nav tab / IndexedStack / GoRouter StatefulShellRoute branch and return |
TickerMode flip (built in — nothing to wire) |
| Push a page over a screen, then pop back | QueryNavigatorObserver (you install it — see below) |
Whether a refetch actually runs follows the controller's refetchOnMount
(always / stale / never). Dismissing a dialog or bottom sheet does
not count as a screen re-entry.
Enable push/pop detection
Add QueryNavigatorObserver to your navigator's observers. A
NavigatorObserver belongs to a single Navigator, so use the shared instance
for one MaterialApp, and a fresh instance per navigator for a GoRouter
StatefulShellRoute:
// Plain MaterialApp — one navigator:
MaterialApp(
navigatorObservers: [QueryNavigatorObserver.instance],
home: const HomeScreen(),
);
// GoRouter with a StatefulShellRoute — root + one per branch:
GoRouter(
observers: [QueryNavigatorObserver()],
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, shell) => ScaffoldWithNavBar(shell),
branches: [
StatefulShellBranch(observers: [QueryNavigatorObserver()], routes: [...]),
StatefulShellBranch(observers: [QueryNavigatorObserver()], routes: [...]),
],
),
],
);
Tab switching works without the observer (it uses TickerMode); the observer is
only needed for Navigator push/pop. Render your query with a Query* widget
(not a raw BlocBuilder) so the visibility is detected.
Note: refetch-on-visible triggers a network refetch. To instead have a screen reflect cache changes made elsewhere without a refetch, have it read the same query key (its
QueryBuilderupdates live via the cache), orinvalidateQueries([...])after a mutation. Two different query keys never sync automatically.
Logging
// Enable via QueryDefaults
QueryDefaults(enableLogging: true, logLevel: Level.FINE)
// Or directly
QueryLogger.enable(level: Level.INFO, onLog: (record) => Sentry.addBreadcrumb(...));
QueryLogger.disable();
Example
A full example app demonstrating all features is in the example/ directory:
- Posts tab —
QueryController,QueryBuilder,MutationController<T, P>typed params,QueryClient.instance.updatecache patching from mutation listeners, background polling, offline support - Products tab —
InfiniteQueryController,setParams()live search,loadMore()on scroll,keepPreviousData,updateInfiniteQuery+prependItemafter create, optimistic item removal - Navigation Refetch tab — a self-contained GoRouter app (bottom nav +
StatefulShellRoutewith three kept-alive tabs and nested routes) demonstrating refetch-on-visible: tab-switch refetch, nested push→pop refetch, interval polling, manual refetch, and a root-provided controller that still refetches from a builder deep in the tree - Widgets tab — live showcase of every widget:
MultiQueryProvider,QueryConsumer,QuerySelector,InfiniteQueryListener,InfiniteQueryConsumer,InfiniteQuerySelector,MultiQueryListener,QueryObserver, error handling withtransformError, and using mutation controllers with all Query* widgets - Issues tab — interactive before/after benchmarks for all v2.0.0 performance and memory optimizations; detailed documentation in
example/lib/features/inefficiency_demos/OPTIMIZATIONS.md