Skip to content

Loading states & errors

Before data arrives, accessors return skeleton values shaped like the schema:

Field kind Skeleton value
Scalar (String!, Int, enum…) null
Object (Rocket!) a skeleton Rocket whose fields are all skeleton values
List of objects ([Launch!]!) a list with one skeleton element
List of scalars ([String!]!) [null]

This means the same build code renders both states. A SkeletonText widget that shows a grey box for null is all the loading UI you need:

widgets/skeleton.dart
class SkeletonText extends StatelessWidget {
const SkeletonText(this.text, {required this.width, this.style});
final String? text;
// …
@override
Widget build(BuildContext context) => text == null
? SkeletonBox(width: width, height: style?.fontSize ?? 15)
: Text(text!, style: style);
}
launch row
final date = launch.date; // DateTime?
final rocketName = launch.rocket?.name;
return CupertinoListTile(
leading: launch.isSkeleton ? const SkeletonBox(width: 28, height: 28) : StatusIcon(launch.status),
title: SkeletonText(launch.name, width: 160),
subtitle: SkeletonText(date == null ? null : '${formatDate(date)} · $rocketName', width: 200),
);

The one-element skeleton list gives you a single placeholder row that has the exact layout of a real row — free glimmer UI.

The third argument to the builder describes the widget’s scope:

QueryBuilder<Query>(
builder: (context, query, state) {
state.isLoading; // a request containing this widget's selections is in flight
state.hasMissingData; // the last build read at least one field not in cache
state.isSkeleton; // hasMissingData && isLoading: gate a whole-widget skeleton on this
state.isStale; // rendering data older than maxAge while it is refetched (see fetch policies)
state.error; // SlingException? of the last request this widget took part in (sticky, see below)
state.refetch(); // Future<void>: refetch everything selected in the last build
state.revalidate(); // refetch only if something read is older than maxAge
},
)

isLoading is what you use for a discreet activity indicator next to the content — the content itself is already rendering as skeletons.

Null from the server vs. not fetched vs. errored

Section titled “Null from the server vs. not fetched vs. errored”

null from an accessor getter is ambiguous on its own — see “null means three things” in Getting started for the full story (the table, state.hasMissingData/state.isSkeleton for the whole widget, accessor.isFetched('field') for one field) and how each case is told apart from the other two. The short version: the cache stores a real null when the server sends one and a missing sentinel for paths never written; only missing records a miss (and therefore a fetch), so a nullable field the server returned as null does not cause refetch loops — and an errored path never gets a real null cached either (see Errors below).

When a request fails — no connection, a timeout, HTTP 4xx/5xx, or a GraphQL errors[] — every scope that took part in it gets state.error (a SlingException) set and is rebuilt.

if (state.error != null) {
return ErrorView(error: state.error!, onRetry: state.refetch);
}

Two behaviours are worth knowing:

  • Errors are sticky. Until you call refetch(), a scope that still misses data (or is stale, or failed a refetch()/background refresh — the cached data stays on screen in those cases) will not schedule a new request for the same document. Without this, a failing query would loop: build → miss → fetch → fail → rebuild → miss → … In practice this means a screen that failed once stays in its error state until the user does something — bind state.refetch to a retry button or, more commonly, to pull-to-refresh (CupertinoSliverRefreshControl/RefreshIndicator) rather than polling.

    If you would rather transient failures heal themselves without user action, pass SlingClient(retryFailedAfter: Duration(seconds: 30)) (or whatever cooldown fits): once that much time has passed since the failure, the next miss for the same document retries it automatically instead of staying sticky forever — useful for a flaky connection or a cold server, at the cost of a possible extra request while the underlying problem is still there. The default (null) keeps errors sticky forever, as above.

  • Partial errors are pruned (by default). GraphQL can return data and errors. sling_gql removes the values at each error’s path before merging, so a null the server put at a failed field is not cached as a real null. The rest of the response is cached normally, and the scope’s error carries the server’s errors. errorPolicy changes that, see below.

  • Retries come first. Network errors, timeouts and 5xx are retried with backoff inside the fetch (see Transport § Retry): the widget stays loading meanwhile, and only the final failure becomes the sticky error — retryFailedAfter’s cooldown starts from there.

state.error is a SlingException, a sealed class: a switch over it is exhaustive, and the compiler tells you when a case is missing.

final message = switch (state.error) {
null => null,
SlingNetworkException() => 'You are offline', // no response at all
SlingTimeoutException() => 'The server is slow, try again',
SlingHttpException(:final statusCode) => 'Server error ($statusCode)',
SlingGraphQLException(:final errors) => errors.first.message,
SlingAuthException() => 'Please sign in again', // refresh failed, see Transport
SlingCancelledException() || SlingTransportException() => 'Something went wrong',
};
Type When Carries
SlingNetworkException no response: offline, DNS, connection refused or reset cause (the http.ClientException)
SlingTimeoutException no response within timeout; the request was aborted timeout
SlingHttpException an HTTP status >= 400 statusCode, body, errors parsed from a GraphQL body
SlingGraphQLException GraphQL errors; isPartial when data came too errors
SlingAuthException the credentials were rejected and refreshing them did not help cause
SlingCancelledException the request was abandoned (every scope waiting for it was disposed) —
SlingTransportException anything else: a custom transport threw, a body was not JSON cause

Every one has a message; statusCode and errors are on the base class too (null / empty when they do not apply). Each GraphQL error is a SlingGraphQLError with the server’s message, path (response keys: sling_gql’s aliases), locations, extensions and code (extensions.code: UNAUTHENTICATED, FORBIDDEN, …):

final e = state.error;
if (e is SlingGraphQLException && e.errors.any((x) => x.code == 'FORBIDDEN')) {
return const Text('You cannot see this launch');
}

e.isNetworkUnreachable is true exactly for SlingNetworkException: the server never answered, so retrying later — when the device is back online — is the right move. Every other error means the server answered (or, for a timeout, may have).

resolve() and client.mutate() throw the same types; MutationBuilder’s and SubscriptionBuilder’s state.error hold them too.

What a scope does with GraphQL errors that come with data — a partial response:

ErrorPolicy Cache state.error resolve / mutate
none (default) errored paths pruned: the fields stay missing the SlingGraphQLException, sticky until refetch() throw it
all written as sent, nulls at errored paths included the error too, sticky until refetch() though nothing is missing throw it, with the value in e.data
ignore written as sent null return the value
QueryBuilder<Query>(
errorPolicy: ErrorPolicy.all, // show what resolved, and say what did not
builder: (context, q, state) => Column(children: [
Text(q.me?.name ?? ''),
if (state.error case SlingGraphQLException(:final errors))
Text('Partly unavailable: ${errors.first.message}'),
]),
)

Set it per widget (QueryBuilder(errorPolicy:), useSlingQuery(errorPolicy:)), per call (createScope, resolve, client.mutate, MutationBuilder’s mutate) or for the whole client (SlingClient(errorPolicy:)). A few rules keep it predictable:

  • It only decides about partial responses. Network, timeout, HTTP and data: null failures are errors under every policy.
  • all and ignore write the server’s null into the shared cache, where every widget reading that field sees a null instead of a miss. In a batch, a root field also selected by a none widget is still pruned (it keeps its “missing” state for that widget); an ignore widget sharing it then shows a placeholder, and does not refetch in a loop.
  • With MutationBuilder and all, a partial response sets both state.data and state.error; the call counts as landed (no rollback, refetchQueries run).

One launch, read with no loading branch: null fields render as placeholders, and an error replaces the card until Retry.

example/lib/demos/errors_demo.dart
class LaunchCard extends StatelessWidget {
const LaunchCard({super.key, required this.builds});
/// Changes on "Rebuild the widget", so the builder runs again.
final int builds;
@override
Widget build(BuildContext context) {
return QueryBuilder<Query>(
builder: (context, query, state) {
final launch = query.launch(id: _launchId);
final name = launch?.name;
final date = launch?.date;
final rocket = launch?.rocket?.name;
if (state.error != null) {
// Sticky: rebuilding lands here again without a new request.
return ErrorView(error: state.error!, onRetry: state.refetch);
}
// No loading branch: null fields render as placeholders.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonText(
name,
width: 180,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 6),
SkeletonText(
date == null ? null : '${formatDate(date)} · $rocket',
width: 150,
),
],
);
},
);
}
}
  • ↻ runs the screen again from an empty cache: placeholders for a moment (the demo’s server takes 1.2 s), then the launch.
  • Server fails, then ↻: placeholders, then the error.
  • Rebuild the widget: the builder runs again and lands on the same error, without a new request. That is the sticky rule: a failing query never retries on its own.
  • Server OK, then Retry (state.refetch): one more request, and the launch.

The example’s Me tab is all of the above in one builder: every field read first, the error with a retry, pull-to-refresh, placeholders while loading, and an empty state that waits for real data:

example/lib/screens/me_screen.dart
child: QueryBuilder<Query>(
builder: (context, query, state) {
final me = query.me;
// Read all scalar fields unconditionally (no-waterfall rule).
final initials = me?.avatarInitials;
final name = me?.name;
final agency = me?.agency;
final count = me?.favoriteCount;
// Each row is a LaunchRow (a SlingRow): its reads join this scope's
// request, so the whole tab is one request, but a favourite
// toggled elsewhere rebuilds only that row.
final favorites = me?.favorites ?? const <Launch>[];
if (state.error != null) {
return ErrorView(error: state.error!, onRetry: state.refetch);
}
return CustomScrollView(
slivers: [
CupertinoSliverRefreshControl(onRefresh: state.refetch),
SliverToBoxAdapter(
child: _ProfileHeader(
initials: initials,
name: name,
agency: agency,
favoriteCount: count,
isLoading: state.isLoading,
),
),
SliverList.builder(
itemCount: favorites.length,
itemBuilder: (context, i) => LaunchRow(favorites[i]),
),
if (favorites.isEmpty && !state.isSkeleton)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'No favourites yet.\nTap the ♡ on a launch to add one.',
textAlign: TextAlign.center,
style: const TextStyle(color: kColorTextSecondary),
),
),
),
),
],
);
},
),
  • favorites.isEmpty && !state.isSkeleton: while loading, favorites is one placeholder launch, so the list shows one skeleton row and never the empty state. The isSkeleton guard makes the rule explicit: “No favourites yet” reacts only to what the server sent, never to a placeholder.
  • CupertinoSliverRefreshControl(onRefresh: state.refetch): pull to refresh is one line, and the same refetch clears a sticky error.

ErrorView is the example’s shared error widget, a message and a retry button:

example/lib/widgets/error_view.dart
/// Shown by a screen's [QueryBuilder] when `state.error` is set (sticky until
/// `refetch()`); what went wrong plus a retry button that calls it.
class ErrorView extends StatelessWidget {
const ErrorView({super.key, required this.error, required this.onRetry});
final SlingException error;
final Future<void> Function() onRetry;
/// The sealed error type says what to tell the user.
String get _summary => switch (error) {
SlingNetworkException() => 'No connection to the server.',
SlingTimeoutException() => 'The server took too long to answer.',
SlingHttpException(:final statusCode) => 'Server error ($statusCode).',
SlingAuthException() => 'Please sign in again.',
SlingGraphQLException(:final errors) =>
errors.isEmpty ? error.message : errors.first.message,
SlingCancelledException() || SlingTransportException() => error.message,
};
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(CupertinoIcons.exclamationmark_triangle, size: 40),
const SizedBox(height: 8),
Text(_summary, textAlign: TextAlign.center),
// On iOS and Android the API is `bun start` on the host; on the
// web it runs in the page, so there is nothing to start.
if (!mockApiInBrowser) ...[
const SizedBox(height: 8),
const Text(
'Is the mock API running? `cd mock-api && bun start`',
style: TextStyle(color: CupertinoColors.systemGrey, fontSize: 13),
),
],
const SizedBox(height: 16),
CupertinoButton.filled(
onPressed: onRetry,
child: const Text('Retry'),
),
],
),
),
);
}