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

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).

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 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. 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 graphqlErrors list.

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
}