sling_gql

A GraphQL client for Flutter where the widget is the query. Inspired by GQty.

Docs: https://tpucci.github.io/sling_gql/ · Source: https://github.com/tpucci/sling_gql

Read a field, get the query. No operation documents, no fragments, no builder-per-query boilerplate: widgets read typed accessors during build(), sling_gql records what was read, batches everything read in the frame into one GraphQL document, fetches it, fills a normalized cache, and rebuilds only the widgets that read the affected data.

QueryBuilder<Query>(
  builder: (context, query, state) {
    final launch = query.latestLaunch;
    return ListTile(
      title: Text(launch?.name ?? '…'),
      subtitle: Text(launch?.rocket?.name ?? '…'),
    );
  },
)

…sends, once, at the end of the frame:

query {
  latestLaunch { __typename name rocket { __typename name } }
}

Getting started

  1. Add the runtime and the generator:

    flutter pub add sling_gql
    flutter pub add --dev sling_gql_gen
    
  2. Generate typed accessors from your schema's introspection JSON (the standard {"__schema": …} result):

    dart run sling_gql_gen --schema graphql/schema.json \
      --out lib/generated/schema.dart --scalar DateTime=DateTime
    
  3. Create a client and provide it to the tree:

    import 'package:sling_gql/sling_gql.dart';
    import 'generated/schema.dart';
    
    void main() {
      final client = SlingClient<Query>(
        endpoint: Uri.parse('https://example.com/graphql'),
        schema: slingSchema, // generated: roots + key field
      );
      runApp(SlingScope<Query>(
        client: client,
        child: const MyApp(),
      ));
    }
    
  4. Read fields in build() with QueryBuilder; mutate with MutationBuilder or client.mutate((m) => m.toggleFavorite(id: id)?.favorite); subscribe with SubscriptionBuilder or client.subscribe((s) => s.launchStatusChanged?..status).

  5. Test with sling_gql_test: MockGraphQLServer answers the recorded documents from plain maps, tester.pumpUntilSettled(client) waits for the round trip.

What's in the box

  • Selection recording during build(), including accessors handed to child widgets and lazily built sliver rows.

  • One HTTP request per frame, arguments turned into variables.

  • Normalized cache (Launch:launch-181), per-field rebuild notifications (only on real changes, inline objects included), evict, gc, snapshot / onChange / changesSince (incremental deltas) for persistence.

  • SlingRow: a list row with its own dependencies, so a write to one entity rebuilds that row instead of the whole list.

  • Skeleton state (null scalars, one-element lists) before data arrives; state.isSkeleton, state.isLoading, state.error, state.refetch().

  • prepare: to fetch fields hidden behind conditionals in the first round trip; dev-mode waterfall warnings.

  • Request log: client.requests (SlingRequest: fields, duration, bytes, error, the widgets that asked for it), logRequests: true for one console line per operation, SlingRequestOverlay for the same list in the app.

  • Mutations with optimistic writes (journaled, rolled back on failure).

  • Offline mutations (offline: true): queued while the server is unreachable, replayed in order, persisted through a MutationQueueStore.

  • A sealed error model (SlingNetworkException, SlingHttpException, SlingGraphQLException with error codes, …), errorPolicy (none / all / ignore), sticky errors, retry cooldown.

  • Auth with single-flight token refresh (SlingClient(auth: SlingAuth(...))), query retry with backoff (RetryPolicy), timeouts, cancellation of requests nobody waits for.

  • Fetch policies per widget (cacheFirst, cacheAndNetwork, networkOnly) and maxAge stale-while-revalidate with state.isStale / revalidate().

  • Type policies (SlingClient(typePolicies:)): key arguments and custom merges per field; RelayStylePagination keeps a cursor connection in one growing cached list, read by PaginatedQueryBuilder. CacheScope.list for list membership.

  • Subscriptions over Server-Sent Events (client.subscribe, SubscriptionBuilder), each event normalized into the cache; subscriptionTransport: for another protocol.

  • Unions and interfaces: generated asLaunch getters (inline fragments) and when(launch: …, rocket: …), all branches recorded on the skeleton.

Rules of thumb

  • Read every field you need at the top of build. A field read only inside an if on fetched data or in a callback costs a second round trip. Use prepare: for the rest.
  • All generated getters are nullable, even for ! schema types: a value can always be absent from cache. Tell a server null from "not fetched yet" with state.hasMissingData.

See the documentation site for the full guide and the example/ app in the repository.

Versioning

From 1.0 the sling_gql packages follow semantic versioning and share a major version. The public API is what package:sling_gql/sling_gql.dart exports, minus members marked @internal (runtime machinery) or @protected (Accessor's helpers, for generated code). package:sling_gql/internal.dart holds the building blocks first-party adapters use: no stability promise, it can change in any minor. Code generated by sling_gql_gen 1.y runs on sling_gql 1.x for every x ≥ y; a newer generator may need a newer runtime (its changelog says which). Deprecated APIs keep working for at least one minor release and are removed only in the next major. Full policy: Versioning & deprecation; coming from 0.x: Upgrading to 1.0. Security issues: SECURITY.md.

Libraries

internal
Internals of sling_gql: the pieces the runtime, its tests and future adapters (persistence, subscriptions) are built from, kept out of package:sling_gql/sling_gql.dart so the app-facing surface stays small.
sling_gql
sling_gql — "read the field, get the query": a GraphQL client for Flutter where the widget is the query.