Skip to content

Getting started

  1. Add the runtime and the generator.

    Terminal window
    flutter pub add sling_gql
    flutter pub add --dev sling_gql_gen

    Both are on pub.dev (sling_gql, sling_gql_gen); they share a major version (see Versioning). To track main instead, use a git: dependency with url: https://github.com/helloprimary/sling_gql and path: packages/sling_gql (resp. packages/sling_gql_gen).

  2. Generate the typed accessors from your endpoint.

    Terminal window
    dart run sling_gql_gen \
    --endpoint https://api.example.com/graphql \
    --out lib/generated/schema.dart

    Add -H "Authorization: Bearer …" if introspection needs a header, or pass --schema introspection.json if you already have the introspection result on disk.

    You get one class per GraphQL type, a Dart enum per 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.

  3. 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 field
    headers: {'Authorization': 'Bearer …'}, // optional; see Transport
    onOperation: (op) => debugPrint(op.document), // optional: log every request
    );
    runApp(SlingScope<Query>(
    client: client,
    child: const CupertinoApp(home: HomeScreen()),
    ));
    }
  4. Read fields inside a QueryBuilder.

    lib/home_screen.dart
    class HomeScreen extends StatelessWidget {
    const HomeScreen({super.key});
    @override
    Widget 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.

  5. 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 null
    if (state.isLoading) const CupertinoActivityIndicator(),
    ]);
    }

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:

Terminal window
git clone https://github.com/helloprimary/sling_gql && cd sling_gql
cd mock-api && bun install && bun start # http://localhost:4000/graphql
Terminal window
# In your app: step 2 above, against the mock API
dart run sling_gql_gen --endpoint http://localhost:4000/graphql --out lib/generated/schema.dart

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

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);
}

Four habits cover almost every mistake sling_gql’s request-counting tests catch:

  1. Read every field the widget might need at the top of build(), into locals — never only inside an if on fetched data or a callback. See Batching & waterfalls.
  2. Use prepare for reads that are legitimately conditional (a collapsed section, a hidden tab) so they are still fetched with the first request. See prepare: fetching for the future.
  3. Never branch on list length while state.hasMissingData — a skeleton list always has one element. See the Aside above and Loading states & errors.
  4. Errors are sticky until refetch() — a failing query never retries on its own. See Loading states & errors.
  • 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.