DartQuery
A production-ready Dart library for reactive in-memory data management with intelligent caching, similar to React Query. Works seamlessly with both Dart and Flutter applications.
β¨ Features
- π Reactive Updates - Automatic UI updates when data changes
- β‘ Smart Caching - Intelligent caching with configurable stale and cache times
- π Cache Size Management - Configurable limits with intelligent eviction policies
- π Request Deduplication - Prevents duplicate API calls for the same data
- πΎ In-Memory Storage - Fast key-value storage accessible across your app
- π― Query Invalidation - Manual cache invalidation and cleanup
- π§ Memory Management - Automatic cleanup and memory pressure handling
- π Cache Monitoring - Real-time statistics and performance metrics
- π§ Flutter Integration - Purpose-built widgets for reactive UI
- π‘οΈ Type Safety - Full TypeScript-like type safety in Dart
- β‘ Performance Optimized - Atomic operations and efficient state management
π¦ Installation
Add DartQuery to your pubspec.yaml
:
dependencies:
dartquery: ^1.0.0
Then run:
flutter pub get
π Quick Start
Basic Usage
import 'package:dartquery/dartquery.dart';
// Simple key-value storage
DartQuery.instance.put('user-id', 'john_123');
String? userId = DartQuery.instance.get<String>('user-id');
// Async data fetching with caching
final userData = await DartQuery.instance.fetch(
'user-profile',
() async => await apiClient.getUserProfile(),
staleTime: Duration(minutes: 5),
);
// Reactive data watching
DartQuery.instance.watch<User>('user-profile').listen((query) {
if (query.isSuccess) {
print('User data: ${query.data?.name}');
}
});
Flutter Integration
import 'package:flutter/material.dart';
import 'package:dartquery/dartquery.dart';
void main() {
runApp(
QueryProvider(
client: QueryClient.instance,
child: MyApp(),
),
);
}
class UserProfile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QueryBuilder<User>(
queryKey: 'user-profile',
fetcher: () => userService.getProfile(),
builder: (context, query) {
if (query.isLoading) {
return CircularProgressIndicator();
}
if (query.isError) {
return Text('Error: ${query.error}');
}
return Text('Hello, ${query.data?.name}!');
},
);
}
}
π Core Concepts
Query States
Every query can be in one of four states:
idle
- Query hasn't been executed yetloading
- Query is currently fetching datasuccess
- Query completed successfully with dataerror
- Query failed with an error
Caching Strategy
DartQuery uses intelligent caching with two key concepts:
- Stale Time - How long data is considered "fresh" (default: 5 minutes)
- Cache Time - How long data stays in memory after becoming unused (default: 10 minutes)
await DartQuery.instance.fetch(
'user-data',
fetcher,
staleTime: Duration(minutes: 10), // Data fresh for 10 minutes
cacheTime: Duration(hours: 1), // Keep in cache for 1 hour
);
π§ API Reference
DartQuery Class
The main entry point for the library.
Methods
put<T>(String key, T data)
Store data immediately with the specified key.
DartQuery.instance.put('settings', {'theme': 'dark'});
get<T>(String key) β T?
Retrieve cached data synchronously.
final settings = DartQuery.instance.get<Map>('settings');
fetch<T>(String key, Future<T> Function() fetcher, {...}) β Future<T>
Fetch data asynchronously with intelligent caching.
final posts = await DartQuery.instance.fetch(
'posts',
() => apiClient.getPosts(),
staleTime: Duration(minutes: 5),
forceRefetch: false,
);
Parameters:
key
- Unique identifier for the datafetcher
- Function that returns the datastaleTime
- Duration data is considered freshcacheTime
- Duration to keep data in cacheforceRefetch
- Ignore cache and always fetch
invalidate(String key)
Mark a query as stale, forcing refetch on next access.
// After updating user data
await updateUserProfile(newData);
DartQuery.instance.invalidate('user-profile');
invalidateAll(List<String> keys)
Invalidate multiple queries atomically.
DartQuery.instance.invalidateAll([
'user-profile',
'user-settings',
'user-preferences'
]);
remove(String key)
Remove data from cache completely.
DartQuery.instance.remove('sensitive-data');
clear()
Clear all cached data.
// On user logout
DartQuery.instance.clear();
watch<T>(String key) β Stream<Query<T>>
Get a reactive stream of query state changes.
DartQuery.instance.watch<User>('user').listen((query) {
print('Status: ${query.status}');
print('Data: ${query.data}');
print('Is loading: ${query.isLoading}');
});
Flutter Widgets
QueryProvider
Provides QueryClient to the widget tree.
QueryProvider(
client: QueryClient.instance, // or custom client
child: MyApp(),
)
QueryBuilder
Automatically manages data fetching and provides reactive UI updates.
QueryBuilder<List<Post>>(
queryKey: 'posts',
fetcher: () => postService.getAllPosts(),
staleTime: Duration(minutes: 10),
enabled: true, // Set to false to disable auto-fetch
builder: (context, query) {
if (query.isLoading) return LoadingSpinner();
if (query.isError) return ErrorWidget(query.error);
return PostList(posts: query.data ?? []);
},
)
QueryConsumer
Lightweight widget for consuming cached data reactively.
QueryConsumer<String>(
queryKey: 'user-status',
builder: (context, query) {
return StatusBadge(status: query.data ?? 'Unknown');
},
)
Query Object
The Query<T>
object represents the state of a cached query.
Properties
T? data // The cached data
Object? error // Error from last failed fetch
QueryStatus status // Current status (idle/loading/success/error)
DateTime? lastUpdated // When data was last updated
bool isLoading // true if currently fetching
bool isSuccess // true if has successful data
bool isError // true if last operation failed
bool isIdle // true if never executed
bool isStale // true if data should be refetched
Methods
Stream<Query<T>> stream // Reactive stream of state changes
ποΈ Advanced Usage
Custom QueryClient
For complex applications, you can create multiple QueryClient instances:
final userClient = QueryClient();
final postClient = QueryClient();
// Use different clients for different data domains
QueryProvider(
client: userClient,
child: UserSection(),
)
Mutations with Cache Updates
// Perform mutation and invalidate related queries
await QueryClient.instance.mutate(
'update-user',
(userData) => userService.updateUser(userData),
newUserData,
invalidateQueries: ['user-profile', 'user-list'],
);
Optimistic Updates
// Update cache immediately, then sync with server
DartQuery.instance.put('user-name', 'New Name');
try {
await userService.updateName('New Name');
} catch (error) {
// Revert on error
DartQuery.instance.invalidate('user-name');
rethrow;
}
Background Refetching
// Set up periodic data refresh
Timer.periodic(Duration(minutes: 5), (_) {
DartQuery.instance.fetch(
'notifications',
() => notificationService.getUnread(),
forceRefetch: true,
);
});
π Query Patterns
Dependent Queries
class UserPostsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return QueryBuilder<User>(
queryKey: 'current-user',
fetcher: () => userService.getCurrentUser(),
builder: (context, userQuery) {
if (userQuery.isLoading) return LoadingSpinner();
if (userQuery.data == null) return LoginPrompt();
// Dependent query - only fetch posts if user is loaded
return QueryBuilder<List<Post>>(
queryKey: 'user-posts-${userQuery.data!.id}',
fetcher: () => postService.getUserPosts(userQuery.data!.id),
builder: (context, postsQuery) {
if (postsQuery.isLoading) return LoadingSpinner();
return PostsList(posts: postsQuery.data ?? []);
},
);
},
);
}
}
Paginated Queries
class InfinitePostsList extends StatefulWidget {
@override
_InfinitePostsListState createState() => _InfinitePostsListState();
}
class _InfinitePostsListState extends State<InfinitePostsList> {
int currentPage = 1;
List<Post> allPosts = [];
Future<void> loadNextPage() async {
final newPosts = await DartQuery.instance.fetch(
'posts-page-$currentPage',
() => postService.getPosts(page: currentPage),
);
setState(() {
allPosts.addAll(newPosts);
currentPage++;
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: allPosts.length + 1,
itemBuilder: (context, index) {
if (index == allPosts.length) {
return LoadMoreButton(onTap: loadNextPage);
}
return PostTile(post: allPosts[index]);
},
);
}
}
Search Queries
class SearchScreen extends StatefulWidget {
@override
_SearchScreenState createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
String searchTerm = '';
Timer? _debounceTimer;
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Duration(milliseconds: 500), () {
setState(() {
searchTerm = value;
});
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: _onSearchChanged,
decoration: InputDecoration(hintText: 'Search...'),
),
if (searchTerm.isNotEmpty)
QueryBuilder<List<SearchResult>>(
queryKey: 'search-$searchTerm',
fetcher: () => searchService.search(searchTerm),
builder: (context, query) {
if (query.isLoading) return LoadingSpinner();
return SearchResults(results: query.data ?? []);
},
),
],
);
}
}
π§ͺ Testing
DartQuery is built with testing in mind. All components are fully testable.
Testing Queries
testWidgets('should display user data', (tester) async {
final client = QueryClient.forTesting();
// Pre-populate test data
client.setQueryData('user', User(name: 'Test User'));
await tester.pumpWidget(
QueryProvider(
client: client,
child: UserProfile(),
),
);
expect(find.text('Test User'), findsOneWidget);
client.dispose();
});
Mocking Network Calls
test('should handle fetch errors', () async {
final mockFetcher = () async => throw 'Network error';
expect(
() => DartQuery.instance.fetch('test', mockFetcher),
throwsA(equals('Network error')),
);
});
π Performance Tips
1. Appropriate Cache Times
// Frequently changing data
DartQuery.instance.fetch(
'live-prices',
fetcher,
staleTime: Duration(seconds: 30),
);
// Rarely changing data
DartQuery.instance.fetch(
'app-config',
fetcher,
staleTime: Duration(hours: 24),
);
2. Query Key Strategies
// β
Good - Specific and cacheable
'user-${userId}'
'posts-${category}-page-${page}'
// β Bad - Too generic or includes timestamps
'user-data'
'posts-${DateTime.now().millisecond}'
3. Selective Invalidation
// β
Good - Invalidate specific related queries
DartQuery.instance.invalidateAll([
'user-profile',
'user-preferences'
]);
// β Bad - Clearing all cache unnecessarily
DartQuery.instance.clear();
4. Widget Optimization
// β
Good - Use QueryConsumer for display-only widgets
QueryConsumer<String>(
queryKey: 'user-status',
builder: (context, query) => StatusWidget(query.data),
)
// β
Good - Use QueryBuilder only when you need to fetch
QueryBuilder<User>(
queryKey: 'user-profile',
fetcher: () => userService.getProfile(),
builder: (context, query) => ProfileWidget(query),
)
π‘οΈ Cache Management & Memory Control
DartQuery provides intelligent cache management to handle large applications and prevent memory issues.
Cache Size Management
// Default configuration (suitable for most apps)
final client = QueryClient.withConfig(CacheConfig());
// Large application configuration
final client = QueryClient.withConfig(CacheConfig.large());
// Memory-constrained configuration
final client = QueryClient.withConfig(CacheConfig.compact());
// Custom configuration
final client = QueryClient.withConfig(CacheConfig(
maxQueries: 200, // Max 200 queries in cache
maxMemoryBytes: 100 * 1024 * 1024, // Max 100MB memory usage
evictionPolicy: EvictionPolicy.lru, // Use LRU eviction
enableMemoryPressureHandling: true, // React to system memory pressure
));
Eviction Policies
Choose the best eviction strategy for your use case:
CacheConfig(
evictionPolicy: EvictionPolicy.lru, // Least Recently Used (default)
evictionPolicy: EvictionPolicy.lrc, // Least Recently Created
evictionPolicy: EvictionPolicy.lfu, // Least Frequently Used
evictionPolicy: EvictionPolicy.ttl, // Time-based (staleness priority)
)
Cache Monitoring
Monitor cache performance and memory usage:
// Get current cache statistics
final stats = client.getCacheStats();
print('Queries: ${stats.queryCount}');
print('Memory: ${(stats.memoryBytes / 1024 / 1024).toStringAsFixed(1)}MB');
print('Hit ratio: ${(stats.hitRatio * 100).toStringAsFixed(1)}%');
print('Evictions: ${stats.evictions}');
// Check if approaching limits
if (client.isCacheNearLimit()) {
print('Cache is approaching configured limits');
}
// Force cleanup
client.cleanup();
Memory Pressure Handling
DartQuery automatically responds to system memory pressure:
// Enable automatic memory pressure handling (default: true)
CacheConfig(enableMemoryPressureHandling: true)
// Manual memory pressure trigger (for testing)
MemoryPressureHandler.instance.triggerMemoryPressure();
// Get memory pressure information
final info = MemoryPressureHandler.instance.getMemoryPressureInfo();
print('Total memory: ${info.memoryMB.toStringAsFixed(1)}MB');
print('Under pressure: ${info.isUnderPressure}');
Automatic Memory Management
DartQuery automatically manages memory to prevent leaks:
- Smart Eviction - Removes least important queries when limits are reached
- Memory Pressure Response - Automatically cleans up on system memory warnings
- Timer Management - All timers are properly cancelled on disposal
- Stream Disposal - Broadcast streams are closed when no longer needed
- Widget Lifecycle - QueryBuilder properly cleans up when disposed
- Listener Tracking - Queries with active listeners are protected from eviction
Manual Cleanup
// Clear specific data when no longer needed
DartQuery.instance.remove('temporary-data');
// Clear all data (e.g., on logout)
DartQuery.instance.clear();
// Force cache cleanup
client.cleanup();
// Dispose custom clients
customClient.dispose();
Cache Configuration Examples
Mobile App (Memory Conscious):
final client = QueryClient.withConfig(CacheConfig(
maxQueries: 50,
maxMemoryBytes: 20 * 1024 * 1024, // 20MB
evictionPolicy: EvictionPolicy.lru,
cleanupInterval: Duration(minutes: 2),
));
Desktop App (Large Dataset):
final client = QueryClient.withConfig(CacheConfig(
maxQueries: 1000,
maxMemoryBytes: 500 * 1024 * 1024, // 500MB
evictionPolicy: EvictionPolicy.lfu,
cleanupInterval: Duration(minutes: 10),
));
Development/Testing (Unlimited):
final client = QueryClient.withConfig(CacheConfig.unlimited());
π Troubleshooting
Common Issues
Q: QueryBuilder not updating when data changes
// β
Ensure you're using the same query key
QueryBuilder<User>(queryKey: 'user-123', ...) // β
DartQuery.instance.put('user-123', newUser); // β
// β Different keys won't sync
QueryBuilder<User>(queryKey: 'user', ...) // β
DartQuery.instance.put('user-123', newUser); // β
Q: Memory leaks in long-running apps
// β
Configure appropriate cache limits
final client = QueryClient.withConfig(CacheConfig(
maxQueries: 100,
maxMemoryBytes: 50 * 1024 * 1024,
evictionPolicy: EvictionPolicy.lru,
));
// β
Monitor cache usage
final stats = client.getCacheStats();
if (stats.memoryBytes > 100 * 1024 * 1024) {
client.cleanup();
}
// β
Use appropriate cache times
DartQuery.instance.fetch(
'temporary-data',
fetcher,
cacheTime: Duration(minutes: 1),
);
Q: Cache growing too large
// β
Enable automatic eviction
final client = QueryClient.withConfig(CacheConfig(
maxQueries: 200, // Limit number of queries
maxMemoryBytes: 100 * 1024 * 1024, // Limit memory usage
evictionPolicy: EvictionPolicy.lru, // Remove least recently used
));
// β
Monitor and alert on cache size
Timer.periodic(Duration(minutes: 5), (_) {
if (client.isCacheNearLimit()) {
print('Warning: Cache approaching limits');
client.cleanup();
}
});
Q: Tests failing due to shared state
// β
Use separate clients for tests
testWidgets('test name', (tester) async {
final client = QueryClient.forTesting();
// ... test code
client.dispose(); // Always dispose
});
Debugging
Enable debug logging to see what DartQuery is doing:
// In your main() function
if (kDebugMode) {
// Query state changes will be logged
DartQuery.instance.watch<dynamic>('*').listen((query) {
print('Query ${query.key}: ${query.status}');
});
}
π€ Contributing
We welcome contributions! Please see our Contributing Guide for details.
Development Setup
git clone https://github.com/your-repo/dartquery.git
cd dartquery
flutter pub get
flutter test
π License
This project is licensed under the MIT License - see the LICENSE file for details.
π Acknowledgments
- Inspired by TanStack Query (formerly React Query)
- Built with β€οΈ for the Dart and Flutter community
Made with β€οΈ by the DartQuery team
For more examples and advanced usage, check out our documentation and examples repository.