Core Concepts

Query States

Query states represent the lifecycle of your data fetching operations. Qora uses sealed classes to provide exhaustive, type-safe state handling that eliminates entire categories of runtime errors.

Overview

Every query in Qora is in one of four states at any given moment:

lib/src/state/qora_state.dart
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

  1. previousData is preserved across Loading and Failure states. The UI always has something to display.
  2. Failure with previousData is not a terminal state. It supports graceful degradation.

Pattern Matching

user_page.dart
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:

user_page.dart
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:

user_page.dart
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.

main.dart
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.

user_page.dart
// 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
user_page.dart
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:

main.dart
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.

user_page.dart
case Success(:final data, :final updatedAt):
  return Column(
    children: [
      UserCard(data),
      Text('Updated ${_timeAgo(updatedAt)}'),
    ],
  );
main.dart
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:

main.dart
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.

user_page.dart
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]),
      ),
    ],
  );
main.dart
state.isError     // true (alias: hasError)
state.hasData     // true when previousData != null
state.dataOrNull  // returns previousData
state.errorOrNull // returns error Object
Always check 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

main.dart
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

main.dart
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

main.dart
// 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

main.dart
// 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:

user_page.dart
// 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:

cache_storage.dart
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

test/user_repository_test.dart
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

main.dart
// 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

main.dart
// 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

Copyright © 2026