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 yet
  • loading - Query is currently fetching data
  • success - Query completed successfully with data
  • error - 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 data
  • fetcher - Function that returns the data
  • staleTime - Duration data is considered fresh
  • cacheTime - Duration to keep data in cache
  • forceRefetch - 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.

Libraries

dartquery