Query States
Overview
Every query in Qora is in one of four states at any given moment:
sealed class QoraState<T> { ... }
// Query hasn't started yet
final class Initial<T> extends QoraState<T> {}
// Actively fetching (with optional stale data)
final class Loading<T> extends QoraState<T> {
final T? previousData;
}
// Data fetched successfully
final class Success<T> extends QoraState<T> {
final T data;
final DateTime updatedAt;
}
// Fetch failed (with optional stale data for graceful degradation)
final class Failure<T> extends QoraState<T> {
final Object error;
final StackTrace? stackTrace;
final T? previousData;
}
Dart 3 sealed classes guarantee exhaustive pattern matching at compile time: the compiler rejects any switch that does not handle all four variants.
State Transitions
Initial
│ first fetch triggered
▼
Loading(previousData: null)
│ fetcher succeeds
▼
Success(data, updatedAt)
│ staleTime elapsed → background refetch
▼
Loading(previousData: data) ← users see stale data during reload
│ fetcher succeeds
▼
Success(newData, updatedAt)
│ fetcher fails after all retries
▼
Failure(error, previousData: data) ← stale data still accessible
Key Principles
previousDatais preserved acrossLoadingandFailurestates. The UI always has something to display.FailurewithpreviousDatais not a terminal state. It supports graceful degradation.
Pattern Matching
Exhaustive switch (recommended)
Widget build(BuildContext context) {
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading(:final previousData) => previousData != null
? Stack(children: [UserCard(previousData), const LinearProgressIndicator()])
: const CircularProgressIndicator(),
Success(:final data) => UserCard(data),
Failure(:final error, :final previousData) => Column(
children: [
if (previousData != null) UserCard(previousData),
ErrorBanner('$error'),
],
),
};
}
when: side-effect dispatch
All four callbacks are optional. when returns void and is designed for side effects: analytics events, snackbars, logging:
state.when(
onSuccess: (data, updatedAt) => analytics.track('loaded', {'id': data.id}),
onError: (error, stackTrace, previousData) => showSnackBar('$error'),
);
maybeWhen: mapping to a value with fallback
maybeWhen returns a value of type R. It accepts an orElse fallback and optional per-state handlers:
final widget = state.maybeWhen(
onSuccess: (data, _) => UserCard(data),
orElse: () => const CircularProgressIndicator(),
);
Initial
The query has never been fetched. This is the default state of any key that has not been loaded yet.
final state = client.getQueryState<User>(['users', 42]);
print(state); // Initial<User>()
print(state.isInitial); // true
print(state.hasData); // false
print(state.dataOrNull); // null
Render a placeholder or nothing. A spinner is inappropriate here because no fetch is in progress.
Loading
A fetch is actively in progress. previousData contains the last successful data (if any), enabling the UI to remain useful during reloads.
// First load: no previousData
Loading(previousData: null) // Show full-screen spinner
// Refetch / background reload: previousData available
Loading(previousData: User(id: 1, name: 'Alice')) // Show stale UI + subtle indicator
case Loading(:final previousData):
if (previousData != null) {
return Stack(
children: [
UserCard(previousData),
const Positioned(
top: 0, left: 0, right: 0,
child: LinearProgressIndicator(),
),
],
);
}
return const CircularProgressIndicator();
Helper properties:
state.isLoading // true
state.isFirstLoad // true when previousData == null
state.isRefreshing // true when previousData != null
state.hasData // true when previousData != null
state.dataOrNull // returns previousData
Success
Data was fetched successfully. Contains data and updatedAt for freshness tracking.
case Success(:final data, :final updatedAt):
return Column(
children: [
UserCard(data),
Text('Updated ${_timeAgo(updatedAt)}'),
],
);
final s = state as Success<User>;
s.data // the fetched User
s.updatedAt // DateTime of the last successful fetch
s.age // Duration since updatedAt
s.isStale(Duration(minutes: 5)) // true if age > 5 min
Helper properties:
state.isSuccess // true
state.hasData // true
state.dataOrNull // returns data
state.successDataOrNull // returns data (null for non-Success states)
state.updatedAt // DateTime?
Failure
The fetch failed after all retry attempts. previousData gives access to the last successful data for graceful degradation.
case Failure(:final error, :final previousData):
return Column(
children: [
if (previousData != null) ...[
UserCard(previousData),
const SizedBox(height: 8),
],
ErrorBanner(
message: '$error',
onRetry: () => client.invalidate(['users', userId]),
),
],
);
state.isError // true (alias: hasError)
state.hasData // true when previousData != null
state.dataOrNull // returns previousData
state.errorOrNull // returns error Object
previousData in Failure. Showing stale data alongside an error banner provides a better experience than a blank error screen.State Extensions
Qora ships a set of extensions on QoraState<T> defined in state_extensions.dart.
Data access
state.hasData // true for Success, or Loading/Failure with previousData
state.dataOrNull // T?: best available data across all states
state.requireData() // T: throws StateError if no data
state.successDataOrNull // T?: only for Success state, null otherwise
Status flags
state.isInitial // true if Initial
state.isLoading // true if Loading
state.isSuccess // true if Success
state.isError // true if Failure
state.hasError // alias for isError
state.isFirstLoad // true if Loading with no previousData
state.isRefreshing // true if Loading with previousData
Mapping
// Transform data type, preserving state structure
final namesState = usersState.map((users) => users.map((u) => u.name).toList());
// usersState: Success<List<User>> → namesState: Success<List<String>>
// mapData behaves like map; use whichever name reads better in a chain
final countState = usersState.mapData((users) => users.length);
// Exhaustive fold to a single value
final label = state.fold(
onInitial: () => 'Not loaded',
onLoading: (prev) => prev != null ? 'Refreshing...' : 'Loading...',
onSuccess: (data, _) => 'Loaded: ${data.name}',
onError: (error, stackTrace, prev) => 'Error: $error',
);
Combining multiple states
// Combine two states: Success only when both are Success
final combined = userState.combine(
postsState,
(user, posts) => UserWithPosts(user, posts),
);
// Combine a list of states
final allUsers = QoraStateUtils.combineList([state1, state2, state3]);
// Combine exactly 2 or 3 states into a record
final pair = QoraStateUtils.combine2(userState, settingsState);
final triple = QoraStateUtils.combine3(userState, postsState, tagsState);
Stream Extensions
watchQuery returns a Stream<QoraState<T>> with additional operators defined in QoraStateStreamExtensions:
// Only emit states that have data
client.watchQuery(...).whereHasData().listen(...);
// Only emit Success states
client.watchQuery(...).whereSuccess().listen((s) => print(s.data));
// Only emit Failure states
client.watchQuery(...).whereError().listen((f) => logError(f.error));
// Extract data as T? (null for states without data)
client.watchQuery(...).dataOrNull().listen(...);
// Extract data: skip states without data
client.watchQuery(...).data().listen((user) => render(user));
// Map data type
client.watchQuery<List<User>>(...).mapData((u) => u.length);
// Delay Loading emissions to prevent flicker on fast connections
client.watchQuery(...).delayLoading(Duration(milliseconds: 300));
Serialization
Persist and restore query states across sessions using QoraStateCodec:
final codec = QoraStateCodec<User>(
encode: (user) => user.toJson(),
decode: (json) => User.fromJson(json as Map<String, dynamic>),
);
// Save to storage
final json = codec.encodeState(state);
await prefs.setString('user_state', jsonEncode(json));
// Restore from storage
final jsonStr = prefs.getString('user_state');
if (jsonStr != null) {
final restored = codec.decodeState(jsonDecode(jsonStr) as Map<String, dynamic>);
client.setQueryData(['users', userId], (restored as Success<User>).data);
}
Testing
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:qora/qora.dart';
class MockUserApi extends Mock implements UserApi {}
void main() {
late QoraClient client;
late MockUserApi api;
setUp(() {
api = MockUserApi();
client = QoraClient();
});
tearDown(() => client.dispose());
test('transitions to Success on successful fetch', () async {
when(() => api.getUser(1)).thenAnswer((_) async => User(id: 1, name: 'Alice'));
final states = <QoraState<User>>[];
final sub = client
.watchQuery<User>(key: ['users', 1], fetcher: () => api.getUser(1))
.listen(states.add);
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(states, [
isA<Initial<User>>(),
isA<Loading<User>>(),
isA<Success<User>>(),
]);
expect((states.last as Success<User>).data.name, 'Alice');
});
test('transitions to Failure on error after retries', () async {
when(() => api.getUser(1)).thenThrow(Exception('Network error'));
final failClient = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(retryCount: 0),
),
);
addTearDown(failClient.dispose);
final states = <QoraState<User>>[];
final sub = failClient
.watchQuery<User>(key: ['users', 1], fetcher: () => api.getUser(1))
.listen(states.add);
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(states.last, isA<Failure<User>>());
});
}
Best Practices
Do
// Handle all four states exhaustively
switch (state) {
case Initial(): return const Placeholder();
case Loading(): return const Spinner();
case Success(): return DataWidget(state.data);
case Failure(): return ErrorWidget(state.error);
}
// Use previousData in Loading and Failure states
case Failure(:final error, :final previousData):
return previousData != null
? DataWidget(previousData)
: ErrorScreen(error);
Avoid
// Partial state handling
if (state is Success) render(state.data); // Failure is silently ignored
// Unsafe cast without prior type check
final data = (state as Success<User>).data; // Throws on Loading or Failure
Next Steps
- Caching: LRU eviction, GC timers, and cache lifecycle
- Stale-While-Revalidate: Background revalidation
- QoraState API Reference: Full API documentation
Query Keys
Query keys are the foundation of Qora's caching system. They uniquely identify queries and enable automatic cache management, invalidation, and refetching.
Caching
Qora's cache enables instant data access. Learn how cache entries are created, aged, and garbage collected, how structural sharing prevents unnecessary rebuilds, and how to control each behavior.