widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Fix setState() After dispose() in Flutter AI Chat Streams

Fix setState() After dispose() in Flutter AI Chat Streams

flutterflutterflowssestreamingmemory-leak

Fix setState() After dispose() in Flutter AI Chat Streams

Your streaming support chat works. Tokens arrive from POST https://api.widgetchat.app/v1/chat/stream, the bubble grows, and then a user hits back halfway through a long answer. The console prints:

setState() called after dispose(): _ChatScreenState#3f2a1(lifecycle state: defunct, not mounted)
This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree...
This error might indicate a memory leak if setState() is being called because another object is retaining a reference to this State object after it has been removed from the tree.

The usual Stack Overflow answer is if (!mounted) return;. That silences the error. It does not fix the bug, and the error message itself tells you why: something is still holding a reference to your State. That something is the SSE stream.

Why a mounted check alone is not enough

Here is the typical code that produces the error:

final response = await http.Client().send(request);
response.stream
    .transform(utf8.decoder)
    .transform(const LineSplitter())
    .listen((line) {
  if (!mounted) return; // the "fix"
  setState(() => _reply += _tokenFrom(line));
});

After the user pops the page:

  • The StreamSubscription is still active, so the listener closure keeps running for every chunk. It captures this, so the dead State and its message list cannot be garbage collected until the stream ends. That is your flutter chat streaming memory leak.
  • The http.Client is never closed, so the socket stays open.
  • The server sees a healthy connection and keeps generating. You pay for tokens nobody will read.
  • If the user opens the chat again, a second stream starts while the first is still running. Do that a few times and you have the classic flutter SSE stream leak on navigate back.

The mounted check hides the symptom. The fix is to make the page own the request and tear it down in dispose().

What actually stops the request

Three things, in this order:

  1. Abort the request. package:http 1.5.0 added AbortableRequest, which takes an abortTrigger future. Completing it aborts the request. If the response has not arrived yet, client.send() throws RequestAbortedException. If it is mid-stream, the exception is added to response.stream and the stream closes.
  2. Cancel the StreamSubscription. This guarantees your onData callback never runs again, whatever the platform client does with buffered chunks.
  3. Close the http.Client. Each chat request gets its own client, so closing it is safe and releases the connection.

The Dart docs note that some client implementations may not support aborting, which is why you do all three instead of trusting one. Add the dependency:

dependencies:
  http: ^1.5.0

A small owner class for the stream

Put the teardown logic in one place so the page cannot forget a step. This class holds the client, the subscription and the abort trigger, and knows nothing about widgets.

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

class ChatStreamOwner {
  http.Client? _client;
  StreamSubscription<String>? _sub;
  Completer<void>? _abort;
  bool _disposed = false;

  bool get isStreaming => _client != null;

  Future<void> start({
    required String apiKey,
    required Map<String, dynamic> body,
    required void Function(String data) onData,
    required void Function() onDone,
    required void Function(Object error) onError,
  }) async {
    if (_disposed) return;
    cancel(); // never run two streams at once

    final abort = _abort = Completer<void>();
    final client = _client = http.Client();

    final request = http.AbortableRequest(
      'POST',
      Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
      abortTrigger: abort.future,
    )
      ..headers['Content-Type'] = 'application/json'
      ..headers['Accept'] = 'text/event-stream'
      ..headers['Authorization'] = 'Bearer $apiKey'
      ..body = jsonEncode(body);

    try {
      final response = await client.send(request);
      // The page may have been popped while we waited for headers.
      if (_disposed || !identical(client, _client)) return;

      _sub = response.stream
          .transform(utf8.decoder)
          .transform(const LineSplitter())
          .listen(
            (line) {
              if (!line.startsWith('data:')) return;
              final data = line.substring(5).trim();
              if (data.isNotEmpty && data != '[DONE]') onData(data);
            },
            onError: (Object e) {
              if (e is! http.RequestAbortedException) onError(e);
              _release(client);
            },
            onDone: () {
              onDone();
              _release(client);
            },
            cancelOnError: true,
          );
    } on http.RequestAbortedException {
      // Cancelled before the first byte. Nothing to report.
    } catch (e) {
      if (!_disposed) onError(e);
      _release(client);
    }
  }

  void cancel() {
    final abort = _abort;
    if (abort != null && !abort.isCompleted) abort.complete();
    _sub?.cancel();
    _client?.close();
    _sub = null;
    _client = null;
    _abort = null;
  }

  void dispose() {
    _disposed = true;
    cancel();
  }

  void _release(http.Client client) {
    if (identical(client, _client)) cancel();
  }
}

A few details that matter:

  • The await gap. Most of the wait in an AI chat happens before the first byte, while the model is thinking. If the user leaves during that gap there is no subscription to cancel yet. Completing the abort trigger handles it, and the _disposed check after await makes sure a late response never gets a listener.
  • identical(client, _client) stops a slow, superseded request from attaching after a newer one started.
  • LineSplitter matters because several SSE events often arrive in one chunk. Log one raw data: line from your stream and parse it to match; do not guess the payload shape.
  • cancel() returns a Future but dispose() is synchronous. Calling it without await is fine: the subscription stops delivering events immediately.

Wiring it into the chat page

The page now has one field and one line in dispose():

class _ChatScreenState extends State<ChatScreen> {
  final _stream = ChatStreamOwner();
  final _messages = <ChatMessage>[];

  Future<void> _send(String text) async {
    setState(() {
      _messages
        ..add(ChatMessage.user(text))
        ..add(ChatMessage.assistant(''));
    });

    await _stream.start(
      apiKey: widget.widgetChatKey,
      body: {'message': text, 'conversation_id': widget.conversationId},
      onData: (data) {
        if (!mounted) return; // belt and braces, no longer the fix
        setState(() => _messages.last = _messages.last.append(_tokenFrom(data)));
      },
      onDone: () { if (mounted) setState(() {}); },
      onError: (e) { if (mounted) _showRetry(e); },
    );
  }

  @override
  void dispose() {
    _stream.dispose(); // abort, cancel, close: before super.dispose()
    super.dispose();
  }
}

The mounted check stays as a cheap guard, but it is no longer doing the real work. With _stream.dispose() in place, popping the page drops the connection, the server sees the client disconnect, and the State can be collected. That is the correct answer to flutter cancel StreamSubscription on dispose and flutter http client close streaming response in one pattern.

If you use a state manager such as Riverpod or Bloc, the same owner moves into the provider or bloc, and its dispose() or close() calls _stream.dispose().

The FlutterFlow version

FlutterFlow makes this harder. A custom action is a top-level Future function, not part of the page's State. The page can be disposed while the action is still awaiting client.send() or reading the stream, and any action-parameter callback that updates page state then fires against a dead page.

The fix is to keep one owner outside the page and let a tiny invisible custom widget cancel it when the page goes away.

Custom action (streamWidgetChat):

// Automatic FlutterFlow imports go here.
import 'package:http/http.dart' as http; // and the ChatStreamOwner class above

final chatStreamOwner = ChatStreamOwner();

Future streamWidgetChat(
  String apiKey,
  String message,
  Future Function(String token) onToken,
) async {
  await chatStreamOwner.start(
    apiKey: apiKey,
    body: {'message': message},
    onData: (data) => onToken(data),
    onDone: () {},
    onError: (e) => debugPrint('chat stream error: $e'),
  );
}

Custom widget (ChatStreamGuard), dropped anywhere on the chat page with a width and height of 0:

// Automatic FlutterFlow imports go here.
import '/custom_code/actions/stream_widget_chat.dart' show chatStreamOwner;

class ChatStreamGuard extends StatefulWidget {
  const ChatStreamGuard({super.key, this.width, this.height});
  final double? width;
  final double? height;

  @override
  State<ChatStreamGuard> createState() => _ChatStreamGuardState();
}

class _ChatStreamGuardState extends State<ChatStreamGuard> {
  @override
  void dispose() {
    chatStreamOwner.cancel(); // page is gone, kill the stream
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

Note that the guard calls cancel(), not dispose(). The owner is a shared object that the next chat page reuses, so it must stay usable. Because the guard lives in the page's widget tree, it is disposed on every exit path: the back button, the system back gesture, and a context.goNamed to another page. That covers the cases a button-only cancel misses.

Part of the stream lifecycle cluster

Leaving the screen is one of three ways a stream should end early. The other two have their own posts:

All three can call the same cancel() on the same owner, which is the point of having one.

Quick checklist

  • One http.Client per chat request, closed when the request ends.
  • Store the StreamSubscription; cancel it in dispose() before super.dispose().
  • Use AbortableRequest so leaving during the "thinking" gap also aborts.
  • Check a disposed flag after every await before attaching a listener.
  • Keep mounted checks as a backstop, not the fix.

Try WidgetChat free

WidgetChat gives your Flutter or FlutterFlow app an AI support chatbot that answers from your own content, streamed token by token over SSE from a plain HTTP endpoint, so the lifecycle code above is all the client code you need. Start on the free tier at widgetchat.app and have streaming replies in your app today.

AbortableRequest in package:http, added in 1.5.0

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!