Skip to content

Read a field.
Get the query.

A GraphQL client for Flutter where your widgets are the query. No documents, no fragments, no hooks-per-operation — just typed Dart.

You write the widget. sling_gql writes the GraphQL.

Every getter you touch during build() is recorded. At the end of the frame, everything read by every widget becomes one request. The response fills a cache and the widgets that read it rebuild.

lib/screens/latest_launch.dart
QueryBuilder<Query>(
builder: (context, query, state) {
final launch = query.latestLaunch;
return ListTile(
title: Text(launch?.name ?? '…'),
subtitle: Text(launch?.rocket?.name ?? '…'),
);
},
)
→
sent to your endpoint
query {
latestLaunch {
__typename
name
rocket {
__typename
name
}
}
}

How it works

Four moving parts, a small runtime, two dependencies (http and meta).

  1. Generated accessors, not proxies

    One Dart class per GraphQL type. Every getter records the field on a selection tree, then reads the cache. Static, typed, tree-shakeable.

  2. Skeletons instead of loading branches

    Before data arrives, scalars are null and lists have one placeholder item. Your layout renders once, with the same code path.

  3. One request per frame

    Selections from every widget — including lazily built list rows — merge into a single document flushed at the end of the frame.

  4. Targeted rebuilds

    The response is normalized: each object is stored once (Launch:launch-181), and only the widgets that read a field that changed rebuild.

A typed API, generated from your schema

One Dart class per GraphQL type. Every field is a real member — autocomplete, jump to definition, doc comments from the schema. Every field is nullable, because it may not be fetched yet: the compiler makes you handle the skeleton state instead of discovering it at runtime.

schema.graphql
type Launch {
id: ID!
name: String!
details: String
status: LaunchStatus!
rocket: Rocket!
payloads: [Payload!]!
}
→
lib/generated/schema.dart
class Launch extends Accessor {
String? get id => scalar<String>('id');
String? get name => scalar<String>('name');
set name(String? v) => write('name', v);
String? get details => scalar<String>('details');
LaunchStatus? get status => enumValue('status', LaunchStatus.fromGraphQL);
Rocket? get rocket => object('rocket', Rocket.new, keyed: true);
List<Payload>? get payloads => list('payloads', Payload.new, keyed: true);
}

Arguments become named parameters, enums become Dart enums, input objects become const classes with toJson(). Regenerate in one command: dart run sling_gql_gen –endpoint …

Status

Queries, the normalized cache, mutations, subscriptions and unions are solid; the cache persists in SQLite through a separate adapter. From 1.0 every package follows a semver and deprecation policy.

Capability Status
Selection recording during build(), incl. child widgets and lazy slivers done
End-of-frame batching across widgets done
Arguments → variables, per-args aliases & cache keys done
Skeleton state, prepare, refetch, sticky errors, partial errors[] done
Optimistic writes done
Code generator (objects, enums, inputs, custom scalars) done
Normalized cache (__typename + id), entity lookups, per-field rebuilds done
Mutations (client.mutate, MutationBuilder, optimistic + rollback) done
Subscriptions (client.subscribe, SubscriptionBuilder, SSE transport) done
Unions / interfaces (asLaunch, when(...), inline fragments) done
Fetch policies, maxAge stale-while-revalidate done
Persistence (sling_gql_sqflite: SQLite, deltas, bounded) done

It started as a feasibility study, built in a day to answer one question: can “the widget is the query” work in Dart, without JavaScript’s Proxy? The answer was yes — read the feasibility notes for what changed along the way.

Dive in