GraphQL Subscriptions in Flutter
•16 min read
GraphQL subscriptions enable real-time updates in your Flutter applications, making them ideal for features like live chats, notifications, and collaborative tools. This guide will walk you through implementing and optimizing GraphQL subscriptions in your Flutter app.
Setting Up Subscriptions
1. Basic Setup
To use subscriptions, ensure your GraphQL server supports WebSocket connections. Update your GraphQLClient
to include a WebSocketLink
:
import 'package:graphql_flutter/graphql_flutter.dart'; final HttpLink httpLink = HttpLink('https://your-graphql-endpoint.com/graphql'); final WebSocketLink webSocketLink = WebSocketLink( 'wss://your-graphql-endpoint.com/graphql', config: SocketClientConfig( autoReconnect: true, inactivityTimeout: Duration(seconds: 30), ), ); final Link link = Link.split( (request) => request.isSubscription, webSocketLink, httpLink, ); final GraphQLClient client = GraphQLClient( link: link, cache: GraphQLCache(store: HiveStore()), );
2. Authentication Setup
class AuthLink extends Link { final String? token; AuthLink(this.token); @override Stream<Response> request(Request request, [NextLink? forward]) async* { if (token != null) { request = request.updateContextEntry<HttpLinkHeaders>( (headers) => HttpLinkHeaders( headers: { ...headers?.headers ?? {}, 'Authorization': 'Bearer $token', }, ), ); } yield* forward!(request); } } // Usage final authLink = AuthLink(userToken); final link = Link.from([ authLink, Link.split( (request) => request.isSubscription, webSocketLink, httpLink, ), ]);
Writing Subscriptions
1. Basic Subscription
subscription OnMessageAdded { messageAdded { id content sender timestamp } }
2. Subscription with Variables
subscription OnChatMessages($chatId: ID!) { chatMessages(chatId: $chatId) { id content sender { id name avatar } timestamp } }
Using Subscriptions in Flutter
1. Basic Subscription Widget
class MessageSubscription extends StatelessWidget { @override Widget build(BuildContext context) { return Subscription( options: SubscriptionOptions( document: gql(r''' subscription OnMessageAdded { messageAdded { id content sender timestamp } } '''), ), builder: (QueryResult result) { if (result.isLoading) { return Center(child: CircularProgressIndicator()); } if (result.hasException) { return ErrorWidget(result.exception!); } final message = result.data?['messageAdded']; return MessageTile(message: message); }, ); } }
2. Subscription with Variables
class ChatSubscription extends StatelessWidget { final String chatId; const ChatSubscription({required this.chatId}); @override Widget build(BuildContext context) { return Subscription( options: SubscriptionOptions( document: gql(r''' subscription OnChatMessages($chatId: ID!) { chatMessages(chatId: $chatId) { id content sender { id name avatar } timestamp } } '''), variables: {'chatId': chatId}, ), builder: (QueryResult result) { if (result.isLoading) { return Center(child: CircularProgressIndicator()); } if (result.hasException) { return ErrorWidget(result.exception!); } final messages = result.data?['chatMessages'] as List?; return ListView.builder( itemCount: messages?.length ?? 0, itemBuilder: (context, index) { final message = messages![index]; return ChatMessageTile(message: message); }, ); }, ); } }
State Management with Subscriptions
1. Using Riverpod
final chatMessagesProvider = StreamProvider.family<List<Message>, String>((ref, chatId) { final client = ref.watch(graphQLClientProvider); return client.subscribe( SubscriptionOptions( document: gql(r''' subscription OnChatMessages($chatId: ID!) { chatMessages(chatId: $chatId) { id content sender { id name } } } '''), variables: {'chatId': chatId}, ), ).map((result) { if (result.hasException) { throw result.exception!; } final messages = result.data?['chatMessages'] as List?; return messages?.map((json) => Message.fromJson(json)).toList() ?? []; }); });
2. Using BLoC
class ChatBloc extends Bloc<ChatEvent, ChatState> { final GraphQLClient client; StreamSubscription? _subscription; ChatBloc({required this.client}) : super(ChatInitial()) { on<SubscribeToChat>(_onSubscribeToChat); on<UnsubscribeFromChat>(_onUnsubscribeFromChat); } Future<void> _onSubscribeToChat( SubscribeToChat event, Emitter<ChatState> emit, ) async { emit(ChatLoading()); _subscription = client.subscribe( SubscriptionOptions( document: gql(r''' subscription OnChatMessages($chatId: ID!) { chatMessages(chatId: $chatId) { id content sender } } '''), variables: {'chatId': event.chatId}, ), ).listen((result) { if (result.hasException) { add(UnsubscribeFromChat()); emit(ChatError(result.exception!)); return; } final messages = result.data?['chatMessages'] as List?; emit(ChatLoaded(messages ?? [])); }); } void _onUnsubscribeFromChat( UnsubscribeFromChat event, Emitter<ChatState> emit, ) { _subscription?.cancel(); emit(ChatInitial()); } @override Future<void> close() { _subscription?.cancel(); return super.close(); } }
Error Handling
1. Connection Errors
class SubscriptionErrorHandler { static void handleError(QueryResult result) { if (result.hasException) { final exception = result.exception!; if (exception is NetworkException) { // Handle network errors debugPrint('Network error: ${exception.message}'); } else if (exception is ServerException) { // Handle server errors debugPrint('Server error: ${exception.message}'); } else { // Handle other errors debugPrint('Error: ${exception.message}'); } } } }
2. Reconnection Strategy
class ReconnectingWebSocketLink extends WebSocketLink { ReconnectingWebSocketLink( String url, { Duration retryInterval = const Duration(seconds: 5), int maxRetries = 5, }) : super( url, config: SocketClientConfig( autoReconnect: true, inactivityTimeout: Duration(seconds: 30), initialPayload: () async => { 'headers': { 'Authorization': 'Bearer ${await getToken()}', }, }, ), ); @override Stream<dynamic> connect() { return super.connect().handleError((error) { debugPrint('WebSocket error: $error'); // Implement custom reconnection logic }); } }
Performance Optimization
1. Subscription Batching
class BatchedSubscription extends StatelessWidget { @override Widget build(BuildContext context) { return Subscription( options: SubscriptionOptions( document: gql(r''' subscription BatchedUpdates { updates { type data } } '''), ), builder: (QueryResult result) { if (result.hasException) { return ErrorWidget(result.exception!); } final updates = result.data?['updates'] as List?; return ListView.builder( itemCount: updates?.length ?? 0, itemBuilder: (context, index) { final update = updates![index]; return UpdateTile(update: update); }, ); }, ); } }
2. Memory Management
class SubscriptionManager { final Map<String, StreamSubscription> _subscriptions = {}; void subscribe(String id, StreamSubscription subscription) { _subscriptions[id] = subscription; } void unsubscribe(String id) { _subscriptions[id]?.cancel(); _subscriptions.remove(id); } void dispose() { _subscriptions.values.forEach((subscription) => subscription.cancel()); _subscriptions.clear(); } }
Testing Subscriptions
1. Unit Tests
void main() { group('Subscription Tests', () { late MockGraphQLClient mockClient; late StreamController<QueryResult> controller; setUp(() { mockClient = MockGraphQLClient(); controller = StreamController<QueryResult>(); when(mockClient.subscribe(any)).thenAnswer((_) => controller.stream); }); test('Subscription receives data', () async { final widget = Subscription( options: SubscriptionOptions( document: gql(r''' subscription OnMessageAdded { messageAdded { id content } } '''), ), builder: (result) => Text(result.data.toString()), ); await tester.pumpWidget( MaterialApp( home: GraphQLProvider( client: mockClient, child: widget, ), ), ); controller.add(QueryResult( data: {'messageAdded': {'id': '1', 'content': 'Hello'}}, )); await tester.pump(); expect(find.text('{messageAdded: {id: 1, content: Hello}}'), findsOneWidget); }); }); }
2. Integration Tests
void main() { group('Subscription Integration Tests', () { late MockWebSocket mockWebSocket; setUp(() { mockWebSocket = MockWebSocket(); when(mockWebSocket.listen(any)).thenReturn(Stream.empty()); }); test('WebSocket connection established', () async { final link = WebSocketLink( 'ws://localhost:8080/graphql', webSocket: mockWebSocket, ); final client = GraphQLClient( link: link, cache: GraphQLCache(), ); await client.subscribe( SubscriptionOptions( document: gql(r''' subscription OnMessageAdded { messageAdded { id } } '''), ), ).first; verify(mockWebSocket.listen(any)).called(1); }); }); }
Best Practices
-
Use Proper Error Handling
Subscription( options: SubscriptionOptions(...), builder: (result) { if (result.hasException) { return ErrorWidget(result.exception!); } // Handle successful result }, );
-
Implement Reconnection Logic
WebSocketLink( 'ws://your-endpoint', config: SocketClientConfig( autoReconnect: true, inactivityTimeout: Duration(seconds: 30), ), );
-
Clean Up Resources
@override void dispose() { subscription?.cancel(); super.dispose(); }
-
Optimize Payload Size
subscription OnUpdate { update { id # Only request needed fields title status } }
Conclusion
Implementing GraphQL subscriptions in Flutter involves:
- Setting up WebSocket connections
- Writing subscription queries
- Handling real-time updates
- Managing state
- Error handling
- Performance optimization
Remember to:
- Handle connection errors gracefully
- Implement proper cleanup
- Optimize subscription payloads
- Test thoroughly
- Monitor performance
With these techniques, you can add real-time capabilities to your Flutter applications effectively!