Skip to content

Querying data

Querying in sling_gql is reading. This page walks through the generated API for the mock API’s schema, showing the GraphQL each snippet produces.

QueryBuilder<Query>(
builder: (context, query, state) {
final company = query.company;
final hq = company?.headquarters;
return Column(children: [
Text(company?.name ?? '…'),
Text('${hq?.city}, ${hq?.country}'),
]);
},
)
generated
query {
company {
__typename
name
headquarters {
__typename
city
country
}
}
}

Three things to notice:

  • Every object selection carries __typename, and keyed types also carry id. Both are added by the printer; together they form the entity key (Launch:launch-181) the normalized cache stores objects under.
  • company?. is nullable because the schema says Company! but the cache may not have it yet. null here means “not fetched”; a server null would also be null. Use state.hasMissingData or company.isSkeleton when you need to tell them apart.
  • Nothing is fetched twice. Reading company.name in three widgets produces one name in one document.

Lists are Dart lists. Before data arrives they contain exactly one skeleton element, so your map/for runs once and records the element’s fields.

final rockets = query.rockets ?? const <Rocket>[];
return Column(
children: [for (final r in rockets) Text('${r.name} · ${r.stages} stages')],
);
generated
query {
rockets {
__typename
name
stages
}
}

Lists of scalars ([String!]!) come back as List<String?>? via scalarList:

final customers = payload.customers?.join(', ');

Fields with arguments are methods with named parameters. Required arguments (ID! without a default) are required; everything else is optional.

final launch = query.launch(id: widget.id);
final page = query.launches(
first: 20,
filter: LaunchFilter(status: LaunchStatus.success, year: 2024),
orderBy: LaunchOrder.dateDesc,
);
generated
query ($id: ID!, $first: Int, $filter: LaunchFilter, $orderBy: LaunchOrder) {
launch_1ov936k1qddray: launch(id: $id) {
__typename
# …
}
launches_1rkvr9g04jfq12: launches(first: $first, filter: $filter, orderBy: $orderBy) {
__typename
# …
}
}
variables
{ "id": "launch-181", "first": 20, "filter": { "status": "SUCCESS", "year": 2024 }, "orderBy": "DATE_DESC" }

Variables are named after the argument ($first, $after, $filter); if the same argument name is used twice with a different value in one operation, the second (and later) distinct value gets a numeric suffix ($first, $first2).

Arguments always travel as variables, so input objects and enums need no client-side serialization beyond toJson(), which the generated input classes implement. The alias (launches_1rkvr9g04jfq12) is a stable hash of the argument values and is also the cache key: launches(first: 20) and launches(first: 40) are different cache entries that can coexist in one document. This is what makes pagination fall out naturally.

final filter = LaunchFilter(
search: 'Starlink',
upcoming: false,
);
query.launchesPage(limit: 10, offset: 0, filter: filter);

LaunchFilter is a plain const class with a toJson() that omits nulls. Nested inputs and lists of inputs are serialized recursively.

A field returning a union or an interface gives you an accessor for the abstract type. Each possible type is an asType getter (the inline fragment ... on Type), and when(...) dispatches on __typename:

search_screen.dart (excerpt)
final hits = (query.search(text: text) ?? const <SearchResult>[]).map(
(hit) => hit.when(
launch: (l) {
final (name, date) = (l.name, l.date);
return _Hit(title: name, subtitle: date == null ? null : formatDate(date));
},
rocket: (r) {
final (name, stages) = (r.name, r.stages);
return _Hit(title: name, subtitle: '$stages stages');
},
astronaut: (a) {
final (name, agency) = (a.name, a.agency);
return _Hit(title: name, subtitle: agency);
},
),
);
generated
query ($text: String!) {
search_1f9qgg312ufxyv: search(text: $text) {
__typename
... on Launch {
id
name__Launch: name
date
}
... on Rocket {
id
name__Rocket: name
stages
}
... on Astronaut {
id
name__Astronaut: name
agency
}
}
}
  • On a skeleton, every branch runs and the first branch’s result is returned (it renders from skeleton accessors, like any skeleton UI). That is how one build records all three fragments and one request serves whichever types come back — the same trick as one-element skeleton lists. Once the data is there only the matching branch runs, and orElse: handles a type added to the schema after you generated (without it, when returns null).
  • Read what a branch shows inside the branch (into locals, as above), not in a child widget built from it: on the skeleton only the returned widget is built, so fields read in the other branches’ widgets would cost a second round trip.
  • asLaunch is null for another type, a skeleton Launch before data arrives. Use it when you care about one member: hit.asLaunch?.name.
  • Members are entities. A keyed member (Launch, with an id) gets its id inside its fragment and is normalized like anywhere else, so the detail screen opened from a search hit finds name and date in cache. An interface declaring the key field (node(id:): Node) selects id on the field itself; its own fields (id) are plain getters next to the asType ones.

A function that reads fields is a fragment. Call it from any widget or from prepare:

launch_screen.dart (excerpt)
static void prepare(Launch launch) {
launch
..name
..date
..status
..details;
launch.rocket
?..name
..description
..successRatePct;
for (final p in launch.payloads ?? const <Payload>[]) {
p..name..type$..orbit;
}
}

Cascade syntax (..) reads a getter for its side effect; the analyzer is fine with it because the getters are real members. See Batching & waterfalls for when to reach for prepare.

Sometimes you need data imperatively — a prefetch, a test, a callback. SlingClient.resolve runs a selection function against a throwaway scope, fetches what is missing, and returns the result once the cache is populated:

final name = await client.resolve((q) => q.latestLaunch?.name);

resolve batches on a microtask instead of a frame, so it also works in pure Dart tests.

The example opens a launch this way: the request leaves on the tap, before the route exists, with the same prepare function the detail screen’s QueryBuilder uses.

example/lib/screens/launch_screen.dart
static void open(BuildContext context, String id) {
SlingScope.of<Query>(context)
.resolve((query) {
final launch = query.launch(id: id);
if (launch != null) _LaunchScreenState.prepare(launch);
})
// The screen surfaces a failure itself (its scope gets the error).
.ignore();
Navigator.of(context)
.push(CupertinoPageRoute<void>(builder: (_) => LaunchScreen(id: id)));
}

The screen builds a frame later, finds the same fields in flight and waits for that request instead of sending its own: still one request, sent earlier. await the resolve before push if you would rather open the screen fully populated (a route “loader”), at the price of a tap that does nothing for a round trip.

state.refetch() re-fetches everything the current widget selected during its last build — the natural thing to hook to pull-to-refresh:

CupertinoSliverRefreshControl(onRefresh: state.refetch)

A connection merged by RelayStylePagination is refetched from its first page: a refresh starts the list over.

To refresh on open, or automatically after a while, see Fetch policies & freshness (fetchPolicy:, maxAge, state.revalidate()).