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, ~600 lines of runtime, one dependency (http).

  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 deep-merged into a path-addressed cache; only widgets that read the affected fields are asked to 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');
String? get status => scalar<String>('status');
Rocket? get rocket => object('rocket', Rocket.new);
List<Payload>? get payloads => list('payloads', Payload.new);
}

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

Proof-of-concept status

Queries, the normalized cache and mutations are solid. Subscriptions and unions are the roadmap, not a promise.

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 (mock API already exposes them over SSE) next
Unions / interfaces ($on) not started
Fetch policies, maxAge stale-while-revalidate done
Persistence adapters not started

This is 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 is yes — read the feasibility notes for what changed along the way.

Dive in