With GraphQL
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
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:
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
}
}
'''),
);
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']);
}
}
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
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']);
}
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:
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
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();
}
}
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
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);
}
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
| Layer | Responsibility |
|---|---|
| Qora cache | SWR, 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:
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:
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:
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:
final client = QoraClient(
config: QoraClientConfig(
errorMapper: mapGraphQLError,
),
);
Cursor-Based Pagination
Use Qora's pagination support with cursor-based connections:
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,
);
}
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:
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
errorMapperand per-querytransformError - Infinite Queries: Proper bi-directional pagination with
InfiniteQueryObserver - With Dio: REST counterpart for teams using both REST and GraphQL