Flutter Integration

QoraBuilder

The widget that fetches data, subscribes to state changes, and rebuilds your UI on every state transition.

QoraBuilder<T> is the primary way to bind server data to Flutter UI. It handles the full query lifecycle: fetching, caching, reactivity, background revalidation: with a single widget declaration.


Basic Usage

user_list_screen.dart
QoraBuilder<List<User>>(
  queryKey: ['users'],
  fetcher: () => api.getUsers(),
  builder: (context, state, fetchStatus) {
    return switch (state) {
      Initial() => const SizedBox.shrink(),
      Loading(:final previousData) => previousData != null
          ? Stack(children: [UserList(previousData), const LinearProgressIndicator()])
          : const CircularProgressIndicator(),
      Success(:final data) => UserList(data),
      Failure(:final error, :final previousData) => previousData != null
          ? Column(children: [UserList(previousData), ErrorBanner(error)])
          : ErrorScreen(error),
    };
  },
)

The builder receives three arguments:

  1. context: the BuildContext
  2. state: the current QoraState<T> (what you have)
  3. fetchStatus: the current FetchStatus (what Qora is doing right now)

Exhaustive pattern-matching over the sealed state is the idiomatic approach. Use fetchStatus for secondary indicators (progress bars, offline chips) without changing the primary state view.


How It Works

On mount, QoraBuilder:

  1. Subscribes to client.watchState<T>(queryKey): an observe-only stream that emits every state update for this key (including updates triggered by other widgets or background services).
  2. Fetches via client.fetchQuery(...) if enabled is true. The client handles deduplication, caching, and stale-while-revalidate automatically.

When the query is externally invalidated (via client.invalidate(), a lifecycle resume, or a reconnect event), the state transitions to Loading(previousData: data). QoraBuilder detects this pattern in the stream and dispatches a fresh fetchQuery. The client's deduplication ensures only one network request fires even if multiple widgets are subscribed to the same key.


Props

PropTypeDefaultDescription
queryKeyObject (List or QoraKey)requiredCache key. Changing it triggers re-subscription and a fresh fetch.
fetcherFuture<T> Function()requiredAsync function that returns the data. Must throw on failure.
builderWidget Function(BuildContext, QoraState<T>, FetchStatus)requiredBuilds the widget tree from the current state and fetch status.
optionsQoraOptions?nullPer-query config merged on top of QoraClientConfig.defaultOptions.
enabledbooltrueWhen false, no fetch is triggered. The widget still observes the current cached state.
keepPreviousDataboolfalseAugments Loading/Failure states with the last known Success data from this widget.
selectObject? Function(T data)?nullRebuild filter: the widget rebuilds only when the state kind or this value changes. The builder still receives the full state.
clientQoraClient?nullOverride the client from QoraScope. Useful for testing or nested scopes.

Patterns

Handling all four states

Use .when() for exhaustive matching or .maybeWhen() when you only care about specific states:

user_screen.dart
QoraBuilder<User>(
  queryKey: ['users', userId],
  fetcher: () => api.getUser(userId),
  builder: (context, state, fetchStatus) {
    return state.when(
      onInitial: () => const SizedBox.shrink(),
      onLoading: (previousData) => previousData != null
          ? UserCard(user: previousData, isRefreshing: true)
          : const CircularProgressIndicator(),
      onSuccess: (user, updatedAt) => UserCard(user: user),
      onError: (error, stackTrace, previousData) => ErrorScreen(
        message: error.toString(),
        onRetry: () => context.qora.invalidate(['users', userId]),
      ),
    );
  },
)

Offline-aware UI with FetchStatus

FetchStatus is the second axis alongside QoraState. Use it to show a background-refresh indicator or an "offline: waiting" message without changing the primary state view:

user_list_screen.dart
QoraBuilder<List<User>>(
  queryKey: ['users'],
  fetcher: api.getUsers,
  builder: (context, state, fetchStatus) {
    return switch (state) {
      Initial() || Loading(previousData: null)
          when fetchStatus == FetchStatus.paused =>
          const Center(child: Text('Offline: connect to load')),
      Initial() || Loading() =>
          const CircularProgressIndicator(),
      Success(:final data) => Column(
          children: [
            if (fetchStatus == FetchStatus.fetching)
              const LinearProgressIndicator(),
            if (fetchStatus == FetchStatus.paused)
              const OfflineSyncChip(),
            UserList(data),
          ],
        ),
      Failure(:final error) => ErrorScreen(error),
    };
  },
)
FetchStatus valueMeaning
FetchStatus.fetchingA network request is in-flight.
FetchStatus.pausedOffline: fetch is queued and will replay on reconnect.
FetchStatus.idleNo active fetch (data is fresh or the state is terminal).
FetchStatus.paused only appears when QoraOptions.networkMode is NetworkMode.online (the default) and the device has no connectivity. If you use NetworkMode.always, fetches run regardless of connectivity and this state is never emitted.

Conditional queries (enabled)

Prevent fetching until a dependency is available:

profile_screen.dart
QoraBuilder<Profile>(
  queryKey: ['profiles', userId],
  fetcher: () => api.getProfile(userId!),
  enabled: userId != null,   // No fetch until userId is resolved
  builder: (context, state, fetchStatus) { ... },
)

When enabled flips from false to true (e.g. via setState), a fetch is triggered automatically.


Performance: derived subscriptions with select

select lets you derive a computed value from the query data. The widget only rebuilds when the selected value changes (compared via ==). Use it to avoid unnecessary rebuilds when only a subset of the data is needed:

user_list_screen.dart
// Rebuild only when the user count changes.
QoraBuilder<List<User>>(
  queryKey: ['users'],
  fetcher: () => api.getUsers(),
  select: (users) => users.length,
  builder: (context, state, fetchStatus) =>
      Text('${state.dataOrNull?.length ?? 0} users'),
)

The builder still receives the full, up-to-date QoraState<T>: select decides when to rebuild, not what is passed. A change of state kind (Loading, Success, Failure) or of FetchStatus always rebuilds, so errors and spinners are never hidden. Without select, the widget rebuilds on every state change.


Flicker-free pagination (keepPreviousData)

When the query key changes (e.g. page number increments), the new key starts in Initial state: causing a jarring full-screen spinner. keepPreviousData prevents this by carrying the last successful data forward into the new key's loading phase:

paginated_posts_screen.dart
QoraBuilder<List<Post>>(
  queryKey: ['posts', currentPage],
  fetcher: () => api.getPosts(page: currentPage),
  keepPreviousData: true,
  builder: (context, state, fetchStatus) {
    // dataOrNull returns data from Success, Loading.previousData, or Failure.previousData
    final posts = state.dataOrNull ?? [];
    return Stack(
      children: [
        PostList(posts: posts),
        if (state.isLoading) const LinearProgressIndicator(),
      ],
    );
  },
)
keepPreviousData only fills in the gap: if the client already supplies previousData in the state, that value takes priority. The widget's local cache is purely a fallback.

Displaying a freshness timestamp

Success carries updatedAt: the moment the data was last fetched:

user_screen.dart
onSuccess: (user, updatedAt) => Column(
  children: [
    UserCard(user: user),
    Text(
      'Last updated ${_timeAgo(updatedAt)}',
      style: Theme.of(context).textTheme.labelSmall,
    ),
  ],
),

Polling

Pass refetchInterval via options to poll on a fixed schedule:

notifications_screen.dart
QoraBuilder<List<Notification>>(
  queryKey: ['notifications'],
  fetcher: api.getNotifications,
  options: const QoraOptions(refetchInterval: Duration(seconds: 30)),
  builder: (context, state) { ... },
)

Polling is active only while the QoraBuilder is in the widget tree. It stops automatically on dispose.


Seeding initial data (initialData)

QoraOptions.initialData seeds the cache with placeholder data before any fetch completes. Use it for SSR hydration, instant renders from shared preferences, or a previous app session:

user_screen.dart
QoraBuilder<User>(
  queryKey: ['users', userId],
  fetcher: () => api.getUser(userId),
  options: QoraOptions(
    initialData: cachedUser,                          // pre-seed the cache
    initialDataUpdatedAt: DateTime.now(),             // mark as fresh
    staleTime: const Duration(minutes: 5),            // skip network if fresh
  ),
  builder: (context, state, fetchStatus) { ... },
)
ParameterDefaultDescription
initialDatanullPlaceholder data of type T to pre-populate the cache.
initialDataUpdatedAtepochTimestamp for staleness calc. Set to DateTime.now() to skip the network.
placeholderDatanullLazy function that derives initial data from another cache entry.

When initialData is provided:

  • If fresh (updatedAt within staleTime): the widget renders with the placeholder immediately and skips the network.
  • If stale (default epoch timestamp): the widget renders with the placeholder and triggers a background SWR refetch. The placeholder is carried via Loading.previousData.
initialData takes precedence over placeholderData. A runtime type mismatch is silently ignored (the cache stays Initial and fetches normally).

Key Changing Behaviour

When queryKey changes (didUpdateWidget), QoraBuilder:

  1. Cancels the previous stream subscription.
  2. Subscribes to the new key's stream (starting from its current cache state: could be Initial, Success, etc.).
  3. Dispatches a fresh fetchQuery for the new key (if enabled).

This is how pagination, tab switching, and filter changes are handled automatically without any extra state management.


QoraStateBuilder: Observe Without Fetching

QoraStateBuilder<T> subscribes to query state without triggering a fetch. Use it when another widget or service owns the fetch lifecycle and you only want to display the current state.

notification_badge.dart
// NotificationsScreen (a QoraBuilder) owns the fetch.
// This badge simply mirrors the cached state of the same key.
QoraStateBuilder<List<Notification>>(
  queryKey: ['notifications'],
  builder: (context, state) {
    final count = state.dataOrNull?.length ?? 0;
    return Badge(
      label: Text('$count'),
      isLabelVisible: count > 0,
      child: const Icon(Icons.notifications),
    );
  },
)

QoraStateBuilder Props

PropTypeDescription
queryKeyObject (List or QoraKey)Key to observe.
builderWidget Function(BuildContext, QoraState<T>)Builds the widget tree from the current state.
clientQoraClient?Override the client from QoraScope.
QoraStateBuilder is ideal for badges, counters, or secondary displays that derive their data from a query fetched elsewhere: for example, a cart item count or an unread message badge in an app bar.

Next Steps

Copyright © 2026