DEV Community

Cover image for Flutter + GraphQL: A Practical Introduction
Muhammad Uzair
Muhammad Uzair

Posted on Originally published at uzairxdev.online

Flutter + GraphQL: A Practical Introduction

REST gets you moving fast, but the moment your app needs exactly the right fields and nothing more, flutter graphql starts to pay for itself. Instead of juggling three endpoints and trimming payloads on the client, you describe the data you want in one query and the server hands it back in exactly that shape. This practical introduction to Flutter and GraphQL walks you through setting up a real client, running queries and mutations, and keeping your cache sane — with code you can drop into a production app today.

Why GraphQL Fits Flutter Apps

A typical REST screen pulls /user, /user/orders, and /user/notifications — three round trips, three models, and over-fetching on every one. With flutter graphql, you ask for user { name orders { id total } } once and get back exactly that. Fewer requests, smaller payloads, and no more DTOs full of fields you never render.

GraphQL is also strongly typed. The schema is a contract you can introspect, which means the client knows the shape of the data before you write a single line of UI code. In Dart terms, that type safety feels familiar — it's why the two fit together so well.

The main client options:

Package Style Best for
graphql_flutter Widgets + hooks Most apps, batteries included
ferry Code-generated Large schemas, strict typing
artemis Codegen only Small teams that want minimal runtime

For this guide we'll use graphql_flutter — it's the pragmatic default and maps cleanly onto Flutter's widget model.

Setting Up graphql_flutter

Add the dependencies:

dependencies:
  graphql_flutter: ^5.1.0
Enter fullscreen mode Exit fullscreen mode

Create a single client that your whole app shares. Use ValueNotifier so GraphQLProvider can react to auth header changes later:

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

final HttpLink httpLink = HttpLink(
  'https://api.example.com/graphql',
  defaultHeaders: {
    'Authorization': 'Bearer <token>',
  },
);

final ValueNotifier<GraphQLClient> client = ValueNotifier(
  GraphQLClient(
    link: httpLink,
    cache: GraphQLCache(store: HiveStore()),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Note HiveStore() — it gives you a normalized, persistent cache out of the box, so queries survive app restarts. Call await initHiveForFlutter(); in main() before runApp, then wrap your MaterialApp with GraphQLProvider:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initHiveForFlutter();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GraphQLProvider(
      client: client,
      child: const MaterialApp(home: UsersScreen()),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Your First Query

Write the query as a plain string and let the Query widget handle loading, error, and data states:

const String usersQuery = r'''
  query GetUsers {
    users {
      id
      name
      avatarUrl
    }
  }
''';

class UsersScreen extends StatelessWidget {
  const UsersScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Team')),
      body: Query(
        options: QueryOptions(
          document: gql(usersQuery),
          fetchPolicy: FetchPolicy.cacheAndNetwork,
        ),
        builder: (QueryResult result, {fetchMore, refetch}) {
          if (result.isLoading && result.data == null) {
            return const Center(child: CircularProgressIndicator());
          }
          if (result.hasException) {
            return Center(child: Text('Error: ${result.exception}'));
          }
          final users = result.data!['users'] as List;
          return ListView.builder(
            itemCount: users.length,
            itemBuilder: (context, i) => ListTile(
              leading: CircleAvatar(
                backgroundImage: NetworkImage(users[i]['avatarUrl']),
              ),
              title: Text(users[i]['name']),
            ),
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noting. First, r''' raw strings keep ${...} and \\ in your GraphQL untouched. Second, cacheAndNetwork shows cached data instantly and refreshes in the background — the sweet spot for most list screens.

Mutations: Writing Data Back

Queries read; mutations write. Use the Mutation widget, which hands you a runMutation callback:

const String createUserMutation = r'''
  mutation CreateUser($name: String!, $email: String!) {
    createUser(name: $name, email: $email) {
      id
      name
    }
  }
''';

Mutation(
  options: MutationOptions(document: gql(createUserMutation)),
  builder: (runMutation, result) {
    return ElevatedButton(
      onPressed: () {
        runMutation({
          'name': nameController.text,
          'email': emailController.text,
        });
      },
      child: const Text('Create user'),
    );
  },
  onCompleted: (data) {
    // The cache updates automatically thanks to the normalized store.
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('User created')),
    );
  },
);
Enter fullscreen mode Exit fullscreen mode

Because the cache is normalized by object id, a mutation that returns the new object with its fields usually updates every screen showing it — no manual refetch() needed in the common case.

Error Handling That Scales

result.exception gives you two error buckets: linkException (network, auth, transport) and graphqlErrors (server-side validation or business logic). Handle them separately:

String userMessage(QueryResult result) {
  final ex = result.exception;
  if (ex == null) return '';
  if (ex.linkException != null) {
    return 'Network error — check your connection and try again.';
  }
  final errors = ex.graphqlErrors;
  if (errors.isNotEmpty) return errors.first.message;
  return 'Something went wrong.';
}
Enter fullscreen mode Exit fullscreen mode

For auth specifically, wire an AuthLink that refreshes tokens and retries — don't hardcode a bearer string forever:

final AuthLink authLink = AuthLink(
  getToken: () async => 'Bearer ${await tokenStorage.read()}',
);
final Link link = authLink.concat(httpLink);
Enter fullscreen mode Exit fullscreen mode

Fetch Policies and Pagination

The default fetch policy is cacheFirst, which is fine for static data. For screens that must be fresh, reach for networkOnly or cacheAndNetwork. For infinite lists, use cursor-based pagination with fetchMore:

builder: (result, {fetchMore, refetch}) {
  final posts = result.data!['posts'] as List;
  return ListView.builder(
    itemCount: posts.length,
    itemBuilder: (context, i) {
      if (i == posts.length - 1) {
        fetchMore?.call(FetchMoreOptions(
          variables: {'after': result.data!['pageInfo']['endCursor']},
          updateQuery: (prev, next) => {
            ...prev!,
            'posts': [...prev['posts'], ...next!['posts']],
          },
        ));
      }
      return PostTile(post: posts[i]);
    },
  );
},
Enter fullscreen mode Exit fullscreen mode

This is where flutter graphql quietly beats most REST helpers: pagination logic lives in one updateQuery closure instead of a pile of manual list-merging code.

Subscriptions: Real-Time Updates

Queries and mutations cover most apps, but feeds, chats, and live dashboards need push. GraphQL subscriptions run over a websocket, and graphql_flutter handles the transport — you just add a WebSocketLink:

final wsLink = WebSocketLink(
  'wss://api.example.com/graphql',
  config: SocketClientConfig(
    initialPayload: () async =>
        {'Authorization': 'Bearer ${await tokenStorage.read()}'},
  ),
);

final Link link = Link.split(
  (request) => request.isSubscription,
  wsLink,
  httpLink, // queries + mutations keep using HTTP
);
Enter fullscreen mode Exit fullscreen mode

Consume it with the Subscription widget, which mirrors the Query widget's builder API:

Subscription(
  options: SubscriptionOptions(
    document: gql(r'''
      subscription OnMessage($roomId: ID!) {
        messageAdded(roomId: $roomId) {
          id
          body
          sender { name }
        }
      }
    '''),
    variables: {'roomId': room.id},
  ),
  builder: (result) {
    if (result.hasException) return Text(userMessage(result));
    final msg = result.data?['messageAdded'];
    if (msg == null) return const SizedBox.shrink();
    return ChatBubble(message: msg);
  },
);
Enter fullscreen mode Exit fullscreen mode

One gotcha: websockets hold a persistent connection, so create the subscription client once at app start and close it on logout, just like you reset the cache.

When to Go Codegen (Ferry)

graphql_flutter uses Map<String, dynamic> — fast to write, but typos in field names surface at runtime. If your schema is large or your team is growing, ferry generates Dart classes from your schema and documents:

// Generated by ferry — fully typed, autocomplete everywhere.
final usersReq = GGetUsersReq();
Enter fullscreen mode Exit fullscreen mode

You trade some setup complexity (build_runner, schema downloads) for compile-time safety. My rule of thumb: fewer than 30 operations → graphql_flutter; beyond that, evaluate ferry.

Pitfalls to Avoid

  • Storing the client per-screen. One shared GraphQLClient keeps the normalized cache consistent. Per-screen clients silently break cache updates.
  • Ignoring hasException. GraphQL returns HTTP 200 even when the query fails — check result.exception every time, or errors render as empty screens.
  • Over-fetching "just in case". The whole point of flutter graphql is precise queries. Ask for what the screen renders, nothing more.
  • Forgetting cache eviction on logout. Call client.value.resetStore() when the user signs out, or the next user sees the previous user's data.

Closing

Flutter and GraphQL are a natural pair: typed schemas meet a typed language, one query replaces three REST calls, and the normalized cache removes a whole class of state bugs. Start with graphql_flutter, add an AuthLink, keep one shared client, and you have a production-ready data layer in an afternoon.

Need a backend to practice against? GitHub's GraphQL API is free, stable, and rich enough to build a real demo app around. And if your app needs a full GraphQL-backed product built right — from schema design to offline-capable Flutter clients — get in touch and let's scope it.


Originally published at uzairxdev.online

Top comments (0)