Back to Posts

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

  1. Use Proper Error Handling

    Subscription(
      options: SubscriptionOptions(...),
      builder: (result) {
        if (result.hasException) {
          return ErrorWidget(result.exception!);
        }
        // Handle successful result
      },
    );
  2. Implement Reconnection Logic

    WebSocketLink(
      'ws://your-endpoint',
      config: SocketClientConfig(
        autoReconnect: true,
        inactivityTimeout: Duration(seconds: 30),
      ),
    );
  3. Clean Up Resources

    @override
    void dispose() {
      subscription?.cancel();
      super.dispose();
    }
  4. 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!