fasq 0.3.7
fasq: ^0.3.7 copied to clipboard
FASQ (Flutter Async State Query) delivers caching-first async data management for Flutter with smart refetching, error recovery, and unified APIs.
FASQ #
⚠️ WARNING: NOT READY FOR PRODUCTION USE
This package is currently in active development and is NOT ready for production use. APIs may change, features may be incomplete, and there may be bugs. Use at your own risk.
A powerful async state management library for Flutter. Handles API calls, database queries, file operations, and any async operation with intelligent caching, automatic refetching, and error recovery.
Current Version: 0.3.4
Status: ⚠️ Development - Not Production Ready
Features #
- ✅ Simple API - Works with any Future-returning function
- ✅ Automatic State Management - Loading, error, and success states handled automatically
- ✅ Intelligent Caching - Automatic caching with staleness detection and configurable freshness
- ✅ Request Deduplication - Concurrent requests for same data trigger only one network call
- ✅ Background Refetching - Stale data served instantly while fresh data loads in background
- ✅ Memory Management - LRU/LFU/FIFO eviction policies with configurable limits
- ✅ Cache Invalidation - Flexible patterns for invalidating cached data
- ✅ Shared Queries - Multiple widgets share the same query and cache
- ✅ Type Safe - Full generic type support for your data
- ✅ Thread Safe - Concurrent access protection with async locks
- ✅ Comprehensive Testing - Extensive test coverage and error handling
- ✅ Infinite Queries - Pagination and infinite scroll with memory management
- ✅ Dependent Queries - Chain queries using enabled gating
- ✅ Offline Mutation Queue - Persist mutations offline and sync when online
- ✅ Security Integration - Plugin architecture for secure storage and encryption
- ✅ Performance Optimization - Hot cache, isolate pool, performance monitoring
Installation #
Add to your pubspec.yaml:
dependencies:
fasq: ^0.3.4
Quick Start #
1. Create a Query #
Use QueryBuilder to execute any async operation and display the results:
import 'package:fasq/fasq.dart';
QueryBuilder<List<User>>(
queryKey: 'users',
queryFn: () => api.fetchUsers(),
builder: (context, state) {
if (state.isLoading) {
return CircularProgressIndicator();
}
if (state.hasError) {
return Text('Error: ${state.error}');
}
if (state.hasData) {
return UserList(users: state.data!);
}
return SizedBox();
},
)
2. Handle Different Async Operations #
FASQ works with any Future-returning function:
API Calls:
QueryBuilder<List<User>>(
queryKey: 'users',
queryFn: () async {
final response = await http.get(Uri.parse('https://api.example.com/users'));
return parseUsers(response.body);
},
builder: (context, state) => buildUI(state),
)
Database Queries:
QueryBuilder<List<Todo>>(
queryKey: 'todos',
queryFn: () => database.getTodos(),
builder: (context, state) => buildUI(state),
)
File Operations:
QueryBuilder<String>(
queryKey: 'config',
queryFn: () => File('config.json').readAsString(),
builder: (context, state) => buildUI(state),
)
Heavy Computations:
QueryBuilder<int>(
queryKey: 'computation',
queryFn: () => compute(heavyCalculation, data),
builder: (context, state) => buildUI(state),
)
3. Share Queries Across Widgets #
Multiple widgets using the same queryKey share the same query instance:
// Widget A
QueryBuilder<Data>(
queryKey: 'shared-data',
queryFn: () => fetchData(),
builder: (context, state) => WidgetA(state),
)
// Widget B (shares the same query!)
QueryBuilder<Data>(
queryKey: 'shared-data',
queryFn: () => fetchData(),
builder: (context, state) => WidgetB(state),
)
Only ONE fetch happens, both widgets receive the same state.
4. Configure Caching Behavior #
Control how long data stays fresh and cached:
QueryBuilder<UserProfile>(
queryKey: 'userProfile',
queryFn: () => api.fetchProfile(),
options: QueryOptions(
staleTime: Duration(minutes: 5), // Data fresh for 5 minutes
cacheTime: Duration(minutes: 10), // Cached for 10 minutes when inactive
),
builder: (context, state) => buildUI(state),
)
What happens:
- First fetch: loads from network, caches for 5 minutes
- Within 5 min: serves instantly from cache, no refetch
- After 5 min: serves from cache, refetches in background
- After 10 min inactive: cache cleared, next access fetches fresh
5. Cache Invalidation #
Invalidate cached data when you know it's changed:
// After updating data
await api.updateUser(user);
// Invalidate the cache
QueryClient().invalidateQuery('user:123');
// Or invalidate multiple
QueryClient().invalidateQueriesWithPrefix('user:');
// Or use custom logic
QueryClient().invalidateQueriesWhere((key) => key.contains('stale'));
6. Manual Cache Updates #
Set cache data manually for optimistic updates:
// Optimistically update cache
QueryClient().setQueryData('user:123', updatedUser);
// Make API call
await api.updateUser(updatedUser);
// Get cached data
final cachedUser = QueryClient().getQueryData<User>('user:123');
7. Monitor Cache Performance #
final info = QueryClient().getCacheInfo();
print('Cache entries: ${info.entryCount}');
print('Cache size: ${info.sizeBytes} bytes');
print('Hit rate: ${info.metrics.hitRate * 100}%');
print('Hits: ${info.metrics.hits}');
print('Misses: ${info.metrics.misses}');
8. Manual Refetch #
Trigger a refetch manually:
final query = QueryClient().getQueryByKey<List<User>>('users');
query?.fetch();
9. Control Query Execution #
Disable automatic fetching with the enabled option:
QueryBuilder<UserPosts>(
queryKey: 'posts',
queryFn: () => api.fetchPosts(userId),
options: QueryOptions(
enabled: userId != null, // Only fetch when userId is available
),
builder: (context, state) => buildUI(state),
)
Core Concepts #
Caching and Staleness #
Flutter Query uses intelligent caching to dramatically improve app performance and user experience.
Three Data States:
-
Fresh Data (age < staleTime)
- Served instantly from cache
- No refetch triggered
- Perfect for data that doesn't change often
-
Stale Data (age >= staleTime)
- Served instantly from cache (no loading state!)
- Background refetch triggered automatically
state.isFetchingindicates background activity- UI updates when fresh data arrives
-
Missing Data (not in cache)
- Must fetch from source
- Shows loading state
- Caches result for future requests
Key Timing Concepts:
- staleTime - How long data is considered fresh (default: 0 = always stale)
- cacheTime - How long inactive data stays in cache (default: 5 minutes)
Example:
QueryOptions(
staleTime: Duration(minutes: 5), // Fresh for 5 min
cacheTime: Duration(minutes: 30), // Kept in cache for 30 min
)
Timeline:
- 0-5 min: Fresh (instant, no refetch)
- 5-30 min: Stale (instant + background refetch)
- 30+ min (inactive): Garbage collected
Request Deduplication #
When 100 widgets request the same data simultaneously:
- Without Flutter Query: 100 network requests
- With Flutter Query: 1 network request, all widgets get the result
This happens automatically, no configuration needed.
Query State #
Every query has a state with these properties:
data- The result of the async operation (null if not loaded)error- The error if the operation failed (null otherwise)stackTrace- Stack trace for debugging errorsstatus- Current status: idle, loading, success, or errorisLoading- Boolean flag for loading statehasData- Boolean flag indicating data is availablehasError- Boolean flag indicating an error occurredisSuccess- Boolean flag for successful completion
Query Lifecycle #
- Creation: Query is created when first widget with that key mounts
- Fetching: Query automatically fetches on first subscriber
- State Updates: All subscribed widgets rebuild when state changes
- Sharing: Additional widgets with same key share the query instance
- Cleanup: Query is disposed 5 seconds after last widget unmounts
Query Key #
The queryKey is a unique string identifier for a query. Widgets with the same key share the same query instance and state.
Best Practices:
- Use descriptive keys:
'users','user:123','posts:user:123' - Include parameters in key for parameterized queries
- Keep keys consistent across your app
Mutations #
Mutations are used for creating, updating, or deleting data (POST, PUT, DELETE operations). Unlike queries, mutations:
- Don't cache results (each execution is unique)
- Are manually triggered (not auto-fetch)
- Are perfect for form submissions and server modifications
Basic Mutation with MutationBuilder #
MutationBuilder<User, CreateUserInput>(
mutationFn: (input) => api.createUser(input),
builder: (context, state, mutate) {
return Column(
children: [
ElevatedButton(
onPressed: state.isLoading
? null
: () => mutate(CreateUserInput(
name: 'John Doe',
email: '[email protected]',
)),
child: state.isLoading
? CircularProgressIndicator()
: Text('Create User'),
),
if (state.hasError)
Text('Error: ${state.error}', style: TextStyle(color: Colors.red)),
if (state.hasData)
Text('Created: ${state.data!.name}'),
],
);
},
)
Form Submission Example #
class CreateUserForm extends StatefulWidget {
@override
State<CreateUserForm> createState() => _CreateUserFormState();
}
class _CreateUserFormState extends State<CreateUserForm> {
final _nameController = TextEditingController();
final _emailController = TextEditingController();
@override
Widget build(BuildContext context) {
return MutationBuilder<User, Map<String, String>>(
mutationFn: (data) async {
final response = await http.post(
Uri.parse('https://api.example.com/users'),
body: json.encode(data),
);
return User.fromJson(json.decode(response.body));
},
options: MutationOptions(
onSuccess: (user) {
QueryClient().invalidateQuery('users');
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('User created: ${user.name}')),
);
},
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $error')),
);
},
),
builder: (context, state, mutate) {
return Column(
children: [
TextField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Name'),
),
TextField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: state.isLoading
? null
: () {
mutate({
'name': _nameController.text,
'email': _emailController.text,
});
},
child: state.isLoading
? CircularProgressIndicator()
: Text('Create User'),
),
if (state.hasError)
Padding(
padding: EdgeInsets.all(8),
child: Text(
'Error: ${state.error}',
style: TextStyle(color: Colors.red),
),
),
],
);
},
);
}
}
Cache Invalidation After Mutation #
After a mutation succeeds, you typically want to invalidate related queries:
MutationBuilder<User, String>(
mutationFn: (userId) => api.deleteUser(userId),
options: MutationOptions(
onSuccess: (deletedUser) {
QueryClient().invalidateQuery('users');
QueryClient().invalidateQuery('user:${deletedUser.id}');
},
),
builder: (context, state, mutate) {
return IconButton(
icon: Icon(Icons.delete),
onPressed: () => mutate('user-123'),
);
},
)
Optimistic Updates #
Update the cache immediately for instant UX, then rollback on error:
MutationBuilder<User, User>(
mutationFn: (user) => api.updateUser(user),
options: MutationOptions(
onMutate: (updatedUser, _) {
final users = QueryClient().getQueryData<List<User>>('users');
final optimistic = users?.map((u) =>
u.id == updatedUser.id ? updatedUser : u
).toList();
QueryClient().setQueryData('users', optimistic);
},
onSuccess: (user) {
QueryClient().invalidateQuery('users');
},
onError: (error) {
QueryClient().invalidateQuery('users');
},
),
builder: (context, state, mutate) {
return ElevatedButton(
onPressed: () => mutate(updatedUser),
child: Text('Update'),
);
},
)
Manual Mutation Class #
For more control, use the Mutation class directly:
class CreateUserScreen extends StatefulWidget {
@override
State<CreateUserScreen> createState() => _CreateUserScreenState();
}
class _CreateUserScreenState extends State<CreateUserScreen> {
late final Mutation<User, String> _createUserMutation;
@override
void initState() {
super.initState();
_createUserMutation = Mutation<User, String>(
mutationFn: (name) => api.createUser(name),
options: MutationOptions(
onSuccess: (user) {
QueryClient().invalidateQuery('users');
},
),
);
_createUserMutation.stream.listen((state) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_createUserMutation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = _createUserMutation.state;
return ElevatedButton(
onPressed: state.isLoading
? null
: () => _createUserMutation.mutate('John Doe'),
child: Text(state.isLoading ? 'Creating...' : 'Create User'),
);
}
}
API Reference #
QueryBuilder #
Widget that executes an async operation and builds UI based on state.
QueryBuilder<T>(
required String queryKey,
required Future<T> Function() queryFn,
required Widget Function(BuildContext, QueryState<T>) builder,
QueryOptions? options,
)
Parameters:
queryKey- Unique identifier for this queryqueryFn- Function that returns a Future with the databuilder- Function that builds UI from query stateoptions- Optional configuration- Emits global observer events with current
BuildContexton loading/success/error
MutationBuilder #
Widget that executes a mutation and builds UI based on its state.
MutationBuilder<T, TVariables>(
required Future<T> Function(TVariables) mutationFn,
required Widget Function(BuildContext, MutationState<T>, Future<void> Function(TVariables)) builder,
MutationOptions<T, TVariables>? options,
)
Parameters:
mutationFn- Function that performs the mutationbuilder- Function that builds UI from mutation state and mutate functionoptions- Optional callbacks (onSuccess, onError, onMutate)
Builder Parameters:
context- BuildContextstate- Current MutationStatemutate- Function to execute the mutation- Automatically forwards state transitions to registered
QueryClientObservers with a freshBuildContext
MutationState #
Immutable state object representing the current mutation status.
Properties:
T? data- The mutation resultObject? error- The error if anyStackTrace? stackTrace- Stack trace for errorsMutationStatus status- Current status enumbool isLoading- True when mutation is executingbool hasData- True when mutation succeededbool hasError- True when mutation failedbool isSuccess- True when mutation completed successfullybool isIdle- True when not yet executed
MutationOptions #
Configuration options for mutations.
MutationOptions<T, TVariables>({
void Function(T data)? onSuccess,
void Function(Object error)? onError,
void Function(T data, TVariables variables)? onMutate,
bool queueWhenOffline = false,
int? maxRetries,
void Function(TVariables variables)? onQueued,
int priority = 0,
MutationMeta? meta,
})
Key parameters:
onSuccess,onError,onMutate,onQueued- Local callbacks for UI-specific workqueueWhenOffline,maxRetries,priority- Offline queue behaviourmeta- Declarative, side-effect free metadata consumed by global observers
MutationMeta #
Declarative metadata describing global side effects for a mutation. Attach to MutationOptions.meta to centralise behaviour.
const MutationMeta(
successMessage: 'Profile updated',
errorMessage: 'Profile update failed',
invalidateKeys: [StringQueryKey('profile')],
refetchKeys: [StringQueryKey('profile:detail')],
triggerCriticalHandler: true,
)
Fields:
successMessage/errorMessage- Display messages (localise/transform viaresolveMessage)invalidateKeys/refetchKeys- Cache actions handled globallytriggerCriticalHandler- Request the registered critical handler to run (logout, navigation, analytics, etc.)
QueryMeta #
The query counterpart mirrors MutationMeta, attached via QueryOptions.meta to describe success/error messages, invalidation, refetch, and whether the critical handler should run for fetch failures.
QueryClientObserver #
Register observers to receive events whenever queries or mutations transition:
final client = QueryClient();
final messenger = EffectMessenger();
GlobalQueryEffects.install(
client: client,
messenger: messenger,
resolveMessage: (id) => AppStrings.toast(id),
onMutationCritical: (snapshot, meta) => authController.reset(),
);
MutationBuilder and QueryBuilder automatically capture a fresh BuildContext and forward it to observers on loading/success/error transitions. Manual Mutation/Query usages still trigger the same notifications with context == null, enabling headless handlers.
QueryState #
Immutable state object representing the current query status.
Properties:
T? data- The fetched dataObject? error- The error if anyStackTrace? stackTrace- Stack trace for errorsQueryStatus status- Current status enumbool isLoading- True when loadingbool hasData- True when data is availablebool hasError- True when error occurredbool isSuccess- True when successfully loadedbool isIdle- True when not yet fetched
QueryOptions #
Configuration options for queries.
QueryOptions({
bool enabled = true,
VoidCallback? onSuccess,
void Function(Object error)? onError,
Duration? staleTime,
Duration? cacheTime,
bool refetchOnMount = false,
bool isSecure = false,
Duration? maxAge,
PerformanceOptions? performance,
QueryMeta? meta,
})
Options:
enabled- Whether the query should execute (default: true)onSuccess- Callback called on successful fetchonError- Callback called on fetch errormeta- Declarative configuration for global observers (messages, invalidation, critical handler)
QueryClient #
Global registry for all queries.
final client = QueryClient();
// Get or create a query
final query = client.getQuery<T>(key, queryFn, options: options);
// Get existing query
final query = client.getQueryByKey<T>('users');
// Manual fetch
query?.fetch();
// Remove a query
client.removeQuery('users');
// Clear all queries
client.clear();
// Check if query exists
bool exists = client.hasQuery('users');
// Get query count
int count = client.queryCount;
Infinite Queries #
Core classes for pagination and infinite scroll:
final query = QueryClient().getInfiniteQuery<List<Item>, int>(
'items',
(page) => api.fetchItems(page),
options: InfiniteQueryOptions(
getNextPageParam: (pages, last) => pages.length + 1,
maxPages: 10,
),
);
await query.fetchNextPage(1);
Offline Mutation Queue #
Queue mutations when offline and process them when connectivity is restored:
MutationBuilder<String, String>(
mutationFn: (data) => api.createPost(data),
options: const MutationOptions(
queueWhenOffline: true,
maxRetries: 3,
onQueued: (variables) {
print('Queued for sync: $variables');
},
),
builder: (context, state, mutate) {
if (state.isQueued) {
return Text('Queued for when online');
}
return ElevatedButton(
onPressed: () => mutate('Hello World'),
child: Text('Submit'),
);
},
)
Monitor network status and queue:
// Check connectivity
bool isOnline = NetworkStatus.instance.isOnline;
// Listen to changes
NetworkStatus.instance.stream.listen((online) {
if (online) {
print('Back online - processing queue');
}
});
// Get queue status
int pendingCount = OfflineQueueManager.instance.length;
Examples #
See the example app for complete working examples:
- API calls with error handling
- Heavy computations
- Multiple widgets sharing queries
- Error recovery patterns
Advanced Configuration #
Global Cache Configuration #
Configure the cache globally for all queries:
final client = QueryClient(
config: CacheConfig(
maxCacheSize: 100 * 1024 * 1024, // 100MB
maxEntries: 2000,
defaultStaleTime: Duration(minutes: 1),
defaultCacheTime: Duration(minutes: 10),
evictionPolicy: EvictionPolicy.lru, // or lfu, fifo
),
);
Eviction Policies #
Choose how the cache decides what to remove when full:
- LRU (default) - Removes least recently accessed entries
- LFU - Removes least frequently accessed entries
- FIFO - Removes oldest entries
Background Refetch Indicator #
Use state.isFetching to show background activity:
QueryBuilder<Data>(
queryKey: 'data',
queryFn: () => fetchData(),
options: QueryOptions(staleTime: Duration(minutes: 5)),
builder: (context, state) {
return Column(
children: [
if (state.isFetching)
LinearProgressIndicator(), // Show background activity
if (state.hasData)
DataWidget(state.data!),
],
);
},
)
Security Features 🔒 #
FASQ includes comprehensive security features for production applications:
Secure Cache Entries #
Mark sensitive data to prevent persistence and enable automatic cleanup:
QueryBuilder<String>(
queryKey: 'auth-token',
queryFn: () => api.getAuthToken(),
options: QueryOptions(
isSecure: true, // Mark as secure
maxAge: Duration(minutes: 15), // Required TTL for secure entries
staleTime: Duration(minutes: 5),
),
builder: (context, state) {
// Secure data is never persisted to disk
// Automatically cleared on app background
return Text('Token: ${state.data}');
},
)
Security Benefits:
- ✅ Never persisted to disk
- ✅ Automatically cleared on app background/termination
- ✅ Strict TTL enforcement
- ✅ Not exposed in DevTools or logs
Encrypted Persistence #
Optional encryption for persisted cache data:
QueryClientProvider(
config: CacheConfig(
defaultStaleTime: Duration(minutes: 5),
defaultCacheTime: Duration(minutes: 10),
),
persistenceOptions: PersistenceOptions(
enabled: true,
encryptionKey: 'your-secure-encryption-key',
),
child: MyApp(),
)
Encryption Features:
- ✅ AES-GCM encryption for data at rest
- ✅ Platform-specific secure key storage (iOS Keychain, Android EncryptedSharedPreferences)
- ✅ Isolate-based encryption for large data (>50KB)
- ✅ Automatic key generation and management
Input Validation #
Comprehensive validation prevents injection attacks:
// Valid query keys
QueryBuilder<String>(
queryKey: 'user:123', // ✅ Valid
queryFn: () => fetchUser(),
)
// Invalid query keys throw clear errors
QueryBuilder<String>(
queryKey: 'user@123', // ❌ Throws: "Query key must contain only alphanumeric, colon, hyphen, underscore"
queryFn: () => fetchUser(),
)
Validation Coverage:
- ✅ Query keys (alphanumeric, colon, hyphen, underscore only)
- ✅ Cache data (no functions or closures)
- ✅ Duration values (non-negative)
- ✅ Clear, actionable error messages
Global Effects & Manual QueryClient Setup #
Create a QueryClient once, register observers, then provide it to the widget tree. Builders will forward context automatically.
final queryClient = QueryClient(
config: const CacheConfig(
defaultCacheTime: Duration(minutes: 10),
),
);
final messenger = EffectMessenger();
void main() {
GlobalQueryEffects.install(
client: queryClient,
messenger: messenger,
resolveMessage: AppStrings.toast,
onMutationCritical: (snapshot, meta) => authController.reset(),
);
runApp(MyApp(
client: queryClient,
messengerKey: messenger.key,
));
}
class MyApp extends StatelessWidget {
const MyApp({required this.client, required this.messengerKey, super.key});
final QueryClient client;
final GlobalKey<ScaffoldMessengerState> messengerKey;
@override
Widget build(BuildContext context) {
return QueryClientProvider(
client: client,
child: MaterialApp(
scaffoldMessengerKey: messengerKey,
home: const HomeScreen(),
),
);
}
}
EffectMessenger is optional—if you omit it, observers rely on the forwarded BuildContext; when neither context nor messenger is available the UI notification is skipped.
Security Configuration #
Configure security features globally:
final secureClient = QueryClient(
config: const CacheConfig(
defaultStaleTime: Duration(minutes: 5),
defaultCacheTime: Duration(minutes: 10),
),
persistenceOptions: const PersistenceOptions(
enabled: true,
),
);
QueryClientProvider(
client: secureClient,
child: const MyApp(),
);
// Access configured client in widgets
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
final client = context.queryClient;
return QueryBuilder<String>(
queryKey: StringQueryKey('secure-data'),
queryFn: () => fetchSecureData(),
options: QueryOptions(
isSecure: true,
maxAge: const Duration(minutes: 30),
),
builder: (context, state) => Text('${state.data}'),
);
}
}
Phase 2 Complete - What's Next #
Phase 2 caching layer is complete! The following features will be added in future phases:
- Phase 3: State management adapters (Hooks, Bloc, Riverpod) ✅
- Phase 4: Infinite queries for pagination ✅
- Phase 4: Dependent queries ✅
- Phase 4: Offline mutation queue ✅
- Phase 5: Production hardening (security, DevTools, testing utilities) ✅
Architecture #
Flutter Query separates async operation logic from UI concerns:
- Query - Pure logic class managing async operations
- QueryState - Immutable state representation
- QueryClient - Global query registry
- QueryBuilder - Flutter widget bridge
This separation enables:
- Easy testing (mock queries, not widgets)
- State sharing across widgets
- Clean architecture
- Future caching layer (Phase 2)
Contributing #
Contributions are welcome! This project is in active development.
License #
MIT License - see LICENSE file for details.
Resources #
- PRD Documentation - Detailed product requirements
- Example App - Working examples
- GitHub Repository
Built with ❤️ for the Flutter community