Third Party

With GraphQL

Wrap graphql or ferry GraphQL clients in Qora fetchers for seamless query management with stale-while-revalidate, cache invalidation, and pagination.

GraphQL APIs benefit from Qora's stale-while-revalidate pattern, automatic invalidation, and normalized cache integration. This guide covers wrapping the graphql and ferry packages in Qora fetchers, error handling, and pagination with cursor-based connections.


Setup

pubspec.yaml
dependencies:
  qora: ^ 1.3.1
  gql: ^1.0.0            # for ferry: gql_http_link, ferry, ferry_cache

Option A: graphql Package

The graphql package provides a GraphQLClient that runs queries and mutations. Wrap each operation in a Qora fetcher:

lib/data/graphql_client.dart
import 'package:graphql/client.dart';
import 'package:qora/qora.dart';

final gqlClient = GraphQLClient(
  link: HttpLink('https://api.example.com/graphql'),
  cache: GraphQLCache(),
);

final queryOptions = QueryOptions(
  document: gql(r'''
    query GetUser($id: ID!) {
      user(id: $id) {
        id
        name
        email
      }
    }
  '''),
);
lib/data/user_api.dart
class UserApi {
  UserApi(this._client);
  final GraphQLClient _client;

  Future<User> getUser(String id) async {
    final result = await _client.query(
      QueryOptions(
        document: gql(r'''
          query GetUser($id: ID!) {
            user(id: $id) { id name email }
          }
        '''),
        variables: {'id': id},
      ),
    );

    if (result.hasException) throw result.exception!;
    return User.fromJson(result.data!['user']);
  }
}
lib/features/user_screen.dart
QoraBuilder<User>(
  queryKey: ['user', userId],
  fetcher: () => userApi.getUser(userId),
  builder: (context, state, _) => switch (state) {
    Success(:final data) => UserCard(data),
    Loading() => const CircularProgressIndicator(),
    Failure(:final error) => ErrorView(error),
    _ => const SizedBox.shrink(),
  },
)

Mutation Example

lib/data/user_api.dart
Future<User> updateUser(String id, String name) async {
  final result = await _client.mutate(
    MutationOptions(
      document: gql(r'''
        mutation UpdateUser($id: ID!, $name: String!) {
          updateUser(id: $id, name: $name) { id name email }
        }
      '''),
      variables: {'id': id, 'name': name},
    ),
  );

  if (result.hasException) throw result.exception!;
  return User.fromJson(result.data!['updateUser']);
}
lib/features/user_edit_screen.dart
QoraMutationBuilder<User, String, void>(
  mutator: (name) => userApi.updateUser(userId, name),
  options: MutationOptions(
    invalidates: [['user', userId]],
  ),
  builder: (context, state, mutate) { ... },
)

Option B: Ferry Package

Ferry provides a typed, code-generated GraphQL client with a built-in normalized cache. Use Qora as the caching layer for view state (UI reactivity, invalidation, SWR) while Ferry handles GraphQL transport and normalized entity storage:

lib/data/ferry_client.dart
import 'package:ferry/ferry.dart';
import 'package:gql_http_link/gql_http_link.dart';
import 'package:qora/qora.dart';

final ferryClient = Client(
  link: HttpLink('https://api.example.com/graphql'),
  cache: Cache(),
);

Query Fetcher

lib/data/user_api.dart
class UserApi {
  UserApi(this._client);
  final Client _client;

  Future<User> getUser(String id) async {
    final req = GGetUserReq((b) => b..vars.id = id);
    final result = await _client.request(req).first;

    if (result.hasErrors) {
      throw result.graphqlErrors!.first;
    }

    return User.fromFragment(result.data!.user);
  }

  Future<List<User>> listUsers({int first = 20, String? cursor}) async {
    final req = GListUsersReq((b) => b
      ..vars.first = first
      ..vars.after = cursor);
    final result = await _client.request(req).first;

    if (result.hasErrors) throw result.graphqlErrors!.first;

    return result.data!.users
        .map((u) => User.fromFragment(u))
        .toList();
  }
}
lib/features/user_list_screen.dart
QoraBuilder<List<User>>(
  queryKey: ['users', first, cursor],
  fetcher: () => userApi.listUsers(first: first, cursor: cursor),
  options: const QoraOptions(staleTime: Duration(minutes: 2)),
  builder: (context, state, _) { ... },
)

Mutation with Optimistic Update

lib/data/user_api.dart
Future<User> updateUserName(String id, String name) async {
  final req = GUpdateUserNameReq((b) => b
    ..vars.id = id
    ..vars.name = name);

  final result = await _client.request(req).first;

  if (result.hasErrors) throw result.graphqlErrors!.first;
  return User.fromFragment(result.data!.updateUserName);
}
lib/features/user_edit_screen.dart
QoraMutationBuilder<User, String, User?>(
  mutator: (name) => userApi.updateUserName(userId, name),
  options: MutationOptions(
    onMutate: (name) {
      // Optimistic: apply the new name immediately to the cached user.
      final previous = context.qora.getQueryData<User>(['user', userId]);
      context.qora.setQueryData(['user', userId],
          previous?.copyWith(name: name) ?? User(name: name));
      return previous;
    },
    onError: (error, variables, previous) {
      // Rollback on failure.
      if (previous != null) {
        context.qora.restoreQueryData(['user', userId], previous);
      }
    },
  ),
  builder: (context, state, mutate) { ... },
)

Normalized Cache Interaction

Qora does not replace Ferry's or Apollo's normalized cache: it lives above it. Use Qora's key-based cache for UI state (is the data fresh? should I re-fetch on mount?), while the GraphQL cache handles normalized entity storage and identity.

Strategy

LayerResponsibility
Qora cacheSWR, invalidation, retry, polling, stale-time management
GraphQL cache (Ferry/Apollo)Normalized entities, data identity, deduplication of nested objects

A common pattern: after a mutation writes to the GraphQL cache, invalidate the corresponding Qora key so the widget refetches:

lib/features/user_edit_screen.dart
onSuccess: () {
  // The GraphQL cache is already updated. Invalidate Qora
  // so the widget tree picks up the fresh data on next rebuild.
  context.qora.invalidate(['user', userId]);
},

Write-through cache with Ferry

Ferry's Cache.writeFragment can update normalized entities immediately. Combine with Qora's setQueryData for an instant UI update:

lib/data/user_api.dart
void optimisticUpdate(String userId, String name) {
  // Write to Ferry's normalized cache.
  _client.cache.writeFragment(
    GUserFragmentReq((b) => b..idFields = {'id': userId}).createRequest(),
    {'id': userId, 'name': name, '__typename': 'User'},
  );
}

Error Handling

GraphQL errors come in two forms: transport-level exceptions and GraphQLError objects in the response body. Map both to domain errors:

lib/data/graphql_error_mapper.dart
import 'package:graphql/client.dart';
import 'package:qora/qora.dart';

/// `errorMapper` must return a `QoraException`, so the domain error extends it.
class GraphQLAppError extends QoraException {
  final bool isNetwork;
  const GraphQLAppError(super.message, {this.isNetwork = false, super.originalError});
}

QoraException mapGraphQLError(Object error, StackTrace? stackTrace) {
  if (error is OperationException) {
    if (error.graphqlErrors.isNotEmpty) {
      final messages = error.graphqlErrors.map((e) => e.message).join(', ');
      return GraphQLAppError(messages, originalError: error);
    }
    if (error.linkException != null) {
      return GraphQLAppError(
        error.linkException.toString(),
        isNetwork: true,
        originalError: error,
      );
    }
  }
  if (error is QoraException) return error;
  return QoraException(error.toString(), originalError: error, stackTrace: stackTrace);
}

Register with Qora's global errorMapper:

lib/core/qora_setup.dart
final client = QoraClient(
  config: QoraClientConfig(
    errorMapper: mapGraphQLError,
  ),
);

Cursor-Based Pagination

Use Qora's pagination support with cursor-based connections:

lib/data/user_api.dart
Future<Connection<User>> listUsers({int first = 20, String? after}) async {
  final req = GListUsersReq((b) => b
    ..vars.first = first
    ..vars.after = after);
  final result = await _client.request(req).first;

  if (result.hasErrors) throw result.graphqlErrors!.first;

  final users = result.data!.users
      .map((u) => User.fromFragment(u))
      .toList();
  final pageInfo = result.data!.usersConnection.pageInfo;

  return Connection(
    nodes: users,
    hasNextPage: pageInfo.hasNextPage,
    endCursor: pageInfo.endCursor,
  );
}
lib/features/user_list_screen.dart
class UserListScreen extends StatefulWidget { ... }

class _UserListScreenState extends State<UserListScreen> {
  String? _cursor;

  @override
  Widget build(BuildContext context) {
    return QoraBuilder<Connection<User>>(
      queryKey: ['users', 20, _cursor],
      fetcher: () => userApi.listUsers(after: _cursor),
      keepPreviousData: true,
      builder: (context, state, _) {
        final users = state.dataOrNull?.nodes ?? [];
        return ListView.builder(
          itemCount: users.length + 1,
          itemBuilder: (_, i) {
            if (i == users.length) {
              return TextButton(
                onPressed: () => setState(() =>
                    _cursor = state.dataOrNull?.endCursor),
                child: const Text('Load more'),
              );
            }
            return UserTile(users[i]);
          },
        );
      },
    );
  }
}

Testing GraphQL Fetchers

Mock the GraphQL client transport link to avoid real network calls:

test/data/user_api_test.dart
import 'package:graphql/client.dart';
import 'package:qora/qora.dart';
import 'package:test/test.dart';

void main() {
  late GraphQLClient gqlClient;
  late QoraClient client;

  setUp(() {
    gqlClient = GraphQLClient(
      link: _MockLink(), // implements Link
      cache: GraphQLCache(),
    );
    client = QoraClient();
  });

  test('fetcher returns parsed User on success', () async {
    final api = UserApi(gqlClient);
    final result = await client.fetchQuery<User>(
      key: ['user', '1'],
      fetcher: () => api.getUser('1'),
    );

    expect(result.name, 'Test User');
  });
}

Next Steps

  • Mutations: Write operations with optimistic updates and automatic rollback
  • Error Handling: Global errorMapper and per-query transformError
  • Infinite Queries: Proper bi-directional pagination with InfiniteQueryObserver
  • With Dio: REST counterpart for teams using both REST and GraphQL
Copyright © 2026