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.
Scalars and nested objects
Section titled “Scalars and nested objects”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}'), ]); },)query { company { __typename name headquarters { __typename city country } }}Three things to notice:
- Every object selection carries
__typename, and keyed types also carryid. 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 saysCompany!but the cache may not have it yet.nullhere means “not fetched”; a servernullwould also benull. Usestate.hasMissingDataorcompany.isSkeletonwhen you need to tell them apart.- Nothing is fetched twice. Reading
company.namein three widgets produces onenamein 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')],);query { rockets { __typename name stages }}Lists of scalars ([String!]!) come back as List<String?>? via scalarList:
final customers = payload.customers?.join(', ');Arguments
Section titled “Arguments”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,);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 # … }}{ "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.
Input objects
Section titled “Input objects”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.
Unions and interfaces
Section titled “Unions and interfaces”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:
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); }, ),);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,whenreturnsnull). - 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.
asLaunchisnullfor another type, a skeletonLaunchbefore data arrives. Use it when you care about one member:hit.asLaunch?.name.- Members are entities. A keyed member (
Launch, with anid) gets itsidinside its fragment and is normalized like anywhere else, so the detail screen opened from a search hit findsnameanddatein cache. An interface declaring the key field (node(id:): Node) selectsidon the field itself; its own fields (id) are plain getters next to theasTypeones.
Reusing selections (“fragments”)
Section titled “Reusing selections (“fragments”)”A function that reads fields is a fragment. Call it from any widget or from prepare:
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.
Reading outside of build()
Section titled “Reading outside of build()”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.
Prefetch on tap
Section titled “Prefetch on tap”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.
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.
Imperative refetch
Section titled “Imperative refetch”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()).