Getting started
-
Add the runtime and the generator.
Terminal window flutter pub add sling_gqlflutter pub add --dev sling_gql_genBoth are on pub.dev (
sling_gql,sling_gql_gen); they share a major version (see Versioning). To trackmaininstead, use agit:dependency withurl: https://github.com/helloprimary/sling_gqlandpath: packages/sling_gql(resp.packages/sling_gql_gen). -
Generate the typed accessors from your endpoint.
Terminal window dart run sling_gql_gen \--endpoint https://api.example.com/graphql \--out lib/generated/schema.dartAdd
-H "Authorization: Bearer …"if introspection needs a header, or pass--schema introspection.jsonif you already have the introspection result on disk.You get one class per GraphQL type, a Dart
enumper enum and a Dart class per input object — see Code generation for the rules. Re-run the command whenever your schema changes; the compiler will tell you what moved. -
Create a client and provide it to the tree.
lib/main.dart import 'package:flutter/cupertino.dart';import 'package:sling_gql/sling_gql.dart';import 'generated/schema.dart';void main() {final client = SlingClient<Query>(endpoint: Uri.parse('https://api.example.com/graphql'),schema: slingSchema, // generated: roots + key fieldheaders: {'Authorization': 'Bearer …'}, // optional; see TransportonOperation: (op) => debugPrint(op.document), // optional: log every request);runApp(SlingScope<Query>(client: client,child: const CupertinoApp(home: HomeScreen()),));} -
Read fields inside a
QueryBuilder.lib/home_screen.dart class HomeScreen extends StatelessWidget {const HomeScreen({super.key});@overrideWidget build(BuildContext context) {return QueryBuilder<Query>(builder: (context, query, state) {final launch = query.latestLaunch;return CupertinoPageScaffold(child: Center(child: Text('${launch?.name ?? '…'} on ${launch?.rocket?.name ?? '…'}'),),);},);}}That is the whole integration. There is no query document anywhere in your code: the first build records
latestLaunch { name rocket { name } }, sling_gql sends it once the frame is done, the cache fills, and the builder runs again with real values. -
Handle loading and errors where you need to.
builder: (context, query, state) {if (state.error != null) {return ErrorView(error: state.error!, onRetry: state.refetch);}final launch = query.latestLaunch;return Stack(children: [LaunchCard(launch), // renders skeletons while nullif (state.isLoading) const CupertinoActivityIndicator(),]);}
Five minutes with the mock API
Section titled “Five minutes with the mock API”No endpoint of your own yet? Try it live runs the example app in your browser. To run it yourself, and generate against a real server:
git clone https://github.com/helloprimary/sling_gql && cd sling_gqlcd mock-api && bun install && bun start # http://localhost:4000/graphql# In your app: step 2 above, against the mock APIdart run sling_gql_gen --endpoint http://localhost:4000/graphql --out lib/generated/schema.dartThe iOS simulator reaches localhost as-is; the Android emulator reaches your
machine at 10.0.2.2 (and needs cleartext HTTP allowed for it). The
example app in the repository is already wired to both.
null means three things
Section titled “null means three things”Every accessor getter is nullable, even for schema fields marked !. null on its own is
ambiguous — it can mean three different things, and telling them apart is the first habit
to build:
| Meaning | How you get it | How to tell |
|---|---|---|
| Not fetched yet | The field has not been read from the server yet (first paint, or a field you have not selected). | state.hasMissingData (whole widget) or accessor.isFetched('field') (one field) is false. |
Server null |
The server answered and the field is genuinely absent (a nullable field with no value). | state.hasMissingData is false and accessor.isFetched('field') is true, yet the getter still returns null. |
| Errored path | The field’s path failed with a GraphQL error; sling_gql never caches a null from an errored path as real data. |
state.error is set. It stays set (see Loading states & errors) until you call state.refetch(). |
The blessed pattern is: gate the whole widget on state.hasMissingData for the
first-paint skeleton (or the shorthand state.isSkeleton, which is exactly
hasMissingData && isLoading), and reach for accessor.isFetched('field') only when a
single field — not the whole screen — needs to tell “not fetched” apart from “the server
said null”:
builder: (context, query, state) { if (state.isSkeleton) return const SkeletonScreen(); // first paint only final launch = query.latestLaunch; final hasDetails = launch?.isFetched('details') ?? false; // per-field, no extra rebuild return LaunchCard(launch, showDetailsPlaceholder: !hasDetails);}Rules of the road
Section titled “Rules of the road”Four habits cover almost every mistake sling_gql’s request-counting tests catch:
- Read every field the widget might need at the top of
build(), into locals — never only inside anifon fetched data or a callback. See Batching & waterfalls. - Use
preparefor reads that are legitimately conditional (a collapsed section, a hidden tab) so they are still fetched with the first request. Seeprepare: fetching for the future. - Never branch on list length while
state.hasMissingData— a skeleton list always has one element. See the Aside above and Loading states & errors. - Errors are sticky until
refetch()— a failing query never retries on its own. See Loading states & errors.
Where to go next
Section titled “Where to go next”- Querying data — arguments, lists, nesting, and what each read becomes on the wire.
- Batching & waterfalls — the one habit to
build: read every field you will need at the top of
build(). - Example app — a complete iOS, Android and web app against the bundled mock API, with a test that asserts request counts.