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);}CupertinoListTile( leading: launch.isSkeleton ? const SkeletonBox(width: 28, height: 28) : Icon(_icon(launch.status)), title: SkeletonText(launch.name, width: 160), subtitle: SkeletonText(date == null ? null : '${date.substring(0, 10)} · $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; // last error from a 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 — transport error, HTTP 4xx/5xx, or a GraphQL errors[] — every
scope that took part in it gets state.error 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. 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 thegraphqlErrorslist.
final e = state.error;if (e is SlingException) { e.message; // joined messages e.graphqlErrors; // List<Map> as returned by the server e.statusCode; // for HTTP failures}