Loading states & errors
Skeletons, not spinners
Section titled “Skeletons, not spinners”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:
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);}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.
QueryState
Section titled “QueryState”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).
Errors
Section titled “Errors”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 arefetch()/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 — bindstate.refetchto 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
dataanderrors. sling_gql removes the values at each error’spathbefore merging, so anullthe server put at a failed field is not cached as a realnull. The rest of the response is cached normally, and the scope’s error carries the server’s errors.errorPolicychanges 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.
The error model
Section titled “The error model”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.
errorPolicy
Section titled “errorPolicy”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: nullfailures are errors under every policy. allandignorewrite the server’snullinto the shared cache, where every widget reading that field sees anullinstead of a miss. In a batch, a root field also selected by anonewidget is still pruned (it keeps its “missing” state for that widget); anignorewidget sharing it then shows a placeholder, and does not refetch in a loop.- With
MutationBuilderandall, a partial response sets bothstate.dataandstate.error; the call counts as landed (no rollback,refetchQueriesrun).
Try it
Section titled “Try it”One launch, read with no loading branch: null fields render as placeholders, and an
error replaces the card until Retry.
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.
Putting it together
Section titled “Putting it together”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:
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,favoritesis one placeholder launch, so the list shows one skeleton row and never the empty state. TheisSkeletonguard 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 samerefetchclears a sticky error.
ErrorView is the example’s shared error widget, a message and a retry button:
/// 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'), ), ], ), ), );}