Initialize Stream Chat in Part of the Widget Tree

If you’re creating a full-scale chat application, you probably want to have Stream Chat Flutter initialized at the top of your widget tree and the Stream user connected as soon as they open the application.

However, if you only need chat functionality in a part of your application, then it’ll be better to delay Stream Chat initialization to when it’s needed. This guide demonstrates three alternative ways for you to initialize Stream Chat Flutter for a part of your widget tree and to only connect a user when needed.

What To Keep In Mind?

Before investigating potential solutions, let’s first take a look at the relevant Stream Chat widgets and classes.

Most of the Stream Chat Flutter UI widgets rely on having a StreamChat ancestor in the widget tree. The StreamChat widget is an InheritedWidget that exposes the StreamChatClient through BuildContext. This widget also initializes the StreamChatCore widget and the StreamChatTheme.

StreamChatCore is a StatefulWidget used to react to life cycle changes and system updates. When the app goes into the background, the WebSocket connection is closed. Conversely, a new connection is initiated when the app is back in the foreground.

What is important to take note of is that a connection is only established if a user is connected.

This means that if you have not yet called client.connectUser(user, token), no connection will be made, and only background listeners will be registered to determine the app’s foreground state.

Option 1: Builder and Connect/Disconnect User

This option requires you to wrap your whole application with the StreamChat widget and to call connectUser and disconnectUser as needed.

This option is the easiest, however, it requires StreamChat to be at the top of each route and as a result, will have a slight overhead as it’ll create the above-mentioned Stream widgets that may not yet be needed.

Exposing the StreamChat Widget

First, you must expose the client and base Stream Chat widgets to the whole application.

void main() {
  final client = StreamChatClient(
    'q29npdvqjr99',
    logLevel: Level.OFF,
  );

  runApp(MyApp(client: client));
}

class MyApp extends StatelessWidget {
  const MyApp({
    Key? key,
    required this.client,
  }) : super(key: key);

  final StreamChatClient client;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      builder: (context, child) {
        return StreamChat(client: client, child: child);
      },
      home: const HomeScreen(),
    );
  }
}

In the above code, you:

  1. Create a StreamChatClient instance
  2. Pass the instance to the StreamChat widget
  3. Expose StreamChat to the whole application within the MaterialApp builder

A few important things to note:

  • The builder wraps the StreamChat widget for every route of our application. No matter where you are in the widget tree, you’ll be able to call StreamChat.of(context).
  • The state will only be created once for our application, as StreamChat is a StatefulWidget and the position of StreamChat in the widget tree remains the same throughout the application lifecycle.
  • No connection will be made until you call connectUser.

Connecting and Disconnecting Users

In MaterialApp above, the home page is set to HomeScreen. This screen could look something like the following:

class HomeScreen extends StatelessWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const Center(
        child: Text('Home Screen'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context).push(
              MaterialPageRoute(builder: (context) => const ChatSetup()));
        },
        child: const Icon(
          Icons.message,
        ),
      ),
    );
  }
}

This screen shows a floating action button that on click navigates the user to the ChatSetup. We want to connect and disconnect a Stream user only when they go to the chat setup screen.

class ChatSetup extends StatefulWidget {
  const ChatSetup({
    super.key,
  });

  @override
  State<ChatSetup> createState() => _ChatSetupState();
}

class _ChatSetupState extends State<ChatSetup> {
  late final Future<OwnUser> connectionFuture;
  late final client = StreamChat.of(context).client;

  @override
  void initState() {
    super.initState();
    connectionFuture = client.connectUser(
      User(id: 'USER_ID'),
      'TOKEN',
    );
  }

  @override
  void dispose() {
    client.disconnectUser();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: FutureBuilder(
        future: connectionFuture,
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          switch (snapshot.connectionState) {
            case ConnectionState.waiting:
              return const Center(
                child: CircularProgressIndicator(),
              );
            default:
              if (snapshot.hasError) {
                return Text('Error: ${snapshot.error}');
              } else {
                return const ChannelListPage();
              }
          }
        },
      ),
    );
  }
}

Within initState you call connectUser; once the future for connectUser has completed a connection to the Stream API is established, you can then display the relevant Stream Chat UI widgets.

Once the ChatScreen widget is disposed of, then disconnectUser will be called within the dispose method.

Caution

In this example, disconnectUser will only be called when the ChatSetup widget is disposed.

You need to ensure that this widget (route) is completely disposed of, or you need to call disconnectUser and connectUser manually when navigating to relevant parts of your application.

For example, let’s say you have a button within one of the chat screens to navigate to a completely different part of your app, then you want to make sure the ChatScreen route is disposed of by forcing it to be removed:

Navigator.of(context).pushAndRemoveUntil(
  MaterialPageRoute(
    builder: ((context) => const HomeScreen()),
  ),
  (Route<dynamic> route) => false,
);

Or let’s say you wanted to pop back to the first route:

Navigator.of(context).popUntil((route) => route.isFirst);

Both of these will ensure the route is disposed of and the user is disconnected as a result.

Option 2: A Nested Navigator

Another approach would be to introduce a new Navigator. This has the benefit that everything related to Stream chat is contained to a specific part of the widget tree.

Defining Routes and Nested Routes

In this example, our application has the following routes.

const routeHome = '/';
const routePrefixChat = '/chat/';
const routeChatHome = '$routePrefixChat$routeChatChannels';
const routeChatChannels = 'chat_channels';
const routeChatChannel = 'chat_channel';

For the /chat/ nested routes (routePrefixChat), this approach initializes Stream Chat in our application and introduces a nested navigator.

Let’s explore the code:

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({
    Key? key,
  }) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    return Provider.value(
      value: navigatorKey,
      child: MaterialApp(
        navigatorKey: navigatorKey,
        initialRoute: routeHome,
        onGenerateRoute: (settings) {
          late Widget page;
          if (settings.name == routeHome) {
            page = const HomeScreen();
          } else if (settings.name!.startsWith(routePrefixChat)) {
            final subRoute = settings.name!.substring(routePrefixChat.length);
            page = ChatSetup(
              setupChatRoute: subRoute,
            );
          } else {
            throw Exception('Unknown route: ${settings.name}');
          }

          return MaterialPageRoute<dynamic>(
            builder: (context) {
              return page;
            },
            settings: settings,
          );
        },
      ),
    );
  }
}

In the above code you’re:

  1. Creating a navigator key, passing it to MaterialApp, and exposing it to the whole application using Provider (you can expose it however you want).
  2. Creating onGenerateRoute that specifies what page to show depending on the route. Most importantly, if the route contains the routePrefixChat, it navigates to the ChatSetup page and passes in the remainder of the route.

For example, Navigator.pushNamed(context, routeChatHome) will navigate to the ChatSetup page and pass in the nested route routeChatChannels.

Stream Chat Initialization, User Connection, and Nested Navigation

Within the ChatSetup widget, we’ll initialize Stream chat, connect a user, and create a new Navigator that handles the sub-navigation for the chat-specific pages.

class ChatSetup extends StatefulWidget {
  const ChatSetup({Key? key, required this.setupChatRoute}) : super(key: key);

  final String setupChatRoute;

  @override
  State<ChatSetup> createState() => _ChatSetupState();
}

class _ChatSetupState extends State<ChatSetup> {
  late final Future<OwnUser> connectionFuture;
  late final client = StreamChatClient(
    'KEY',
    logLevel: Level.OFF,
  );

  @override
  void initState() {
    super.initState();
    connectionFuture = client.connectUser(
      User(id: 'USER_ID'),
      'TOKEN',
    );
  }

  @override
  void dispose() {
    client.disconnectUser();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Material(
      child: StreamChat(
        client: client,
        child: FutureBuilder(
          future: connectionFuture,
          builder: (BuildContext context, AsyncSnapshot snapshot) {
            switch (snapshot.connectionState) {
              case ConnectionState.waiting:
                return const Center(
                  child: CircularProgressIndicator(),
                );
              default:
                if (snapshot.hasError) {
                  return Text('Error: ${snapshot.error}');
                } else {
                  return Navigator(
                    initialRoute: widget.setupChatRoute,
                    onGenerateRoute: _onGenerateRoute,
                  );
                }
            }
          },
        ),
      ),
    );
  }

  Route _onGenerateRoute(RouteSettings settings) {
    late Widget page;
    switch (settings.name) {
      case routeChatChannels:
        page = ChannelListPage(
          client: client,
        );
        break;
      case routeChatChannel:
        final channel = settings.arguments as Channel;
        page = StreamChannel(
          channel: channel,
          child: const ChannelPage(),
        );
        break;
      default:
        throw Exception('Unknown route: ${settings.name}');
    }
    return MaterialPageRoute<dynamic>(
      builder: (context) {
        return page;
      },
      settings: settings,
    );
  }
}

This ChatSetup widget is similar to what it was in the first option, the only difference is that we’re also introducing a nested navigator and handling those nested routes.

The steps for the above code are:

  1. Create a StreamChatClient instance
  2. Call connectUser within initState and await the result using a FutureBuilder
  3. Introduce a StreamChat widget into the widget tree
  4. Introduce a new Navigator for the chat-specific routes
  5. Call disconnectUser within dispose

Displaying Stream Chat UI Widgets and Global Navigation

Then finally, the ChannelListPage could look something like the following:

class ChannelListPage extends StatefulWidget {
  const ChannelListPage({
    super.key,
    required this.client,
  });

  final StreamChatClient client;

  @override
  State<ChannelListPage> createState() => _ChannelListPageState();
}

class _ChannelListPageState extends State<ChannelListPage> {
  late final _controller = StreamChannelListController(
    client: widget.client,
    filter: Filter.in_(
      'members',
      [StreamChat.of(context).currentUser!.id],
    ),
    channelStateSort: const [SortOption('last_message_at')],
  );

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _popChatPages() {
    final nav = context.read<GlobalKey<NavigatorState>>();
    nav.currentState!.pop();
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async {
        _popChatPages();
        return true;
      },
      child: Scaffold(
        appBar: AppBar(
          leading: BackButton(
            onPressed: () {
              _popChatPages();
            },
          ),
        ),
        body: RefreshIndicator(
          onRefresh: _controller.refresh,
          child: StreamChannelListView(
            controller: _controller,
            onChannelTap: (channel) {
              Navigator.pushNamed(context, routeChatChannel,
                  arguments: channel);
            },
          ),
        ),
      ),
    );
  }
}

There are two important things to note in the above code:

  • We’re accessing the global NavigatorState using Provider and calling pop() on the back press. This will ensure that this entire route is disposed of and that the Stream connection is closed.
  • Within onChannelTap we’re calling pushNamed and passing in the route to display a single channel page. This uses the navigator introduced in ChatSetup, which is the closest navigator within the widget tree.

A few things to take note of:

  • You’ll always need to access the global navigator if you want to dispose of this route. you need to ensure that this route is popped or replaced, otherwise, the Stream connection will remain active for as long as the chat route is on the stack (or manually disconnected).

Option 3: Navigator 2.0 - Using GoRouter

This final example will be a combination of the first two options. The following code is an example of how to initialize Stream Chat in a part of the widget tree using the GoRouter package. This solution will change depending on how you do routing in your Flutter application and which package (if any) you use.

Application Routes and Conditional Stream Initialization

For this example we have the following routes and nested routes:

|_ '/' -> home page
	|_ 'settings/' - settings page
	|_ 'chat/' - chat home, shows the channels list page
		|_ 'channel/' - specific channel page

In the MyApp widget, we’ll initialize GoRouter and the StreamChatClient. However, we will only expose the StreamChat widget for certain routes. Additionally, we’ll create a ChatSetup widget that connects and disconnects the user. This widget will also only be injected for the /chat routes.

class MyApp extends StatefulWidget {
  const MyApp({
    Key? key,
  }) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final _client = StreamChatClient(
    'q29npdvqjr99',
    logLevel: Level.OFF,
  );
  bool wasPreviousRouteChat = false;

  late final _router = GoRouter(
    initialLocation: '/',
    routes: [
      ShellRoute(
        builder: (context, state, child) {
          if (state.uri.host.startsWith('/chat')) {
            wasPreviousRouteChat = true;
            return StreamChat(
              client: _client,
              child: ChatSetup(client: _client, child: child),
            );
          } else {
            if (wasPreviousRouteChat) {
              wasPreviousRouteChat = false;
              return StreamChat(
                client: _client,
                child: child,
              );
            } else {
              return child;
            }
          }
        },
        routes: [
          GoRoute(
            path: '/',
            builder: (context, state) => const HomeScreen(),
            routes: [
              GoRoute(
                path: 'settings',
                builder: (context, state) => const SettingsPage(),
              ),
              GoRoute(
                path: 'chat',
                builder: (context, state) => const ChannelListPage(),
                routes: [
                  GoRoute(
                    path: 'channel',
                    builder: (context, state) {
                      final channel = state.extra as Channel;
                      return StreamChannel(
                        channel: channel,
                        child: const ChannelPage(),
                      );
                    },
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: _router.routeInformationParser,
      routeInformationProvider: _router.routeInformationProvider,
      routerDelegate: _router.routerDelegate,
    );
  }
}

If you’re unfamiliar with GoRouter it will help to first read the documentation and then come back to this guide.

There are a few important things to note in the above code:

  • Define our routes and nested routes
  • Specify the initial route with initialLocation
  • Use the navigatorBuilder to wrap certain routes with StreamChat and ChatSetup.
    • The wasPreviousRouteChat **boolean is used to determine if the previous route was a chat route. This is important because when you press the back button from the /chat route and navigate to the / route, theStreamChatwidget still needs to be accessible while the navigation transition occurs. However, if you then navigate to the /setting route, you no longer need theStreamChat** widget and can safely remove it.

Within the HomeScreen you can create a button that on press navigates to the /chat route:

GoRouter.of(context).go('/chat');

Connecting and Disconnecting Users

Same as before, we’ll use the ChatSetup widget to connect and disconnect a user. This time the widget also takes in a child widget to display once the connection is finished. The child widget is dependent on the route we’re navigating to.

class ChatSetup extends StatefulWidget {
  const ChatSetup({
    Key? key,
    required this.client,
    required this.child,
  }) : super(key: key);

  final StreamChatClient client;
  final Widget child;

  @override
  State<ChatSetup> createState() => _ChatSetupState();
}

class _ChatSetupState extends State<ChatSetup> {
  late final Future<OwnUser> connectionFuture;

  @override
  void initState() {
    super.initState();
    connectionFuture = widget.client.connectUser(
      User(id: 'USER_ID'),
      'TOKEN',
    );
  }

  @override
  void dispose() {
    widget.client.disconnectUser();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Material(
      child: FutureBuilder(
        future: connectionFuture,
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          switch (snapshot.connectionState) {
            case ConnectionState.waiting:
              return const Center(
                child: CircularProgressIndicator(),
              );
            default:
              if (snapshot.hasError) {
                return Text('Error: ${snapshot.error}');
              } else {
                return widget.child;
              }
          }
        },
      ),
    );
  }
}

This will connect the Stream chat user within initState and disconnect the user on dispose. This is similar to the previous examples we explored.

Displaying the Stream UI Widgets

The ChannelListPage can look something like the following:

class ChannelListPage extends StatefulWidget {
  const ChannelListPage({
    super.key,
  });

  @override
  State<ChannelListPage> createState() => _ChannelListPageState();
}

class _ChannelListPageState extends State<ChannelListPage> {
  late final client = StreamChat.of(context).client;
  late final _controller = StreamChannelListController(
    client: client,
    filter: Filter.in_(
      'members',
      [StreamChat.of(context).currentUser!.id],
    ),
    channelStateSort: const [SortOption('last_message_at')],
  );

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: RefreshIndicator(
        onRefresh: _controller.refresh,
        child: StreamChannelListView(
          controller: _controller,
          onChannelTap: (channel) {
            GoRouter.of(context).go('/chat/channel', extra: channel);
          },
        ),
      ),
    );
  }
}

This is the same as before, the only difference is that the navigation is slightly different; now we’re navigating to the /chat/channel route for individual channel pages.

Conclusion

This guide demonstrated three different ways to initialize Stream Chat in a part of the Flutter widget tree.

There are two key takeaways

  1. Accessing StreamChat depends on your location in the widget tree
  2. How you ultimately decide to expose StreamChat and connect users will be up to your application architecture and how you manage routing.

The above are only examples that can be refined and tweaked to suit your needs.

© Getstream.io, Inc. All Rights Reserved.