Flutter & Riverpod: Managing State for a Multi-Room Chat Application

06 Mar 2026

9K

35K

Flutter & Riverpod: Managing State for a Multi-Room Chat Application

Introduction

Building real-time applications like multi-room chat can be a complex endeavor, especially when it comes to managing the application's state efficiently and robustly. Flutter, with its declarative UI, provides an excellent foundation, but handling intricate state – such as user authentication, a list of chat rooms, and real-time messages within each room – requires a powerful and scalable state management solution. This article explores how Flutter, combined with Riverpod, an immutable and compile-safe state management library, offers an elegant and maintainable architecture for a multi-room chat application.

The Challenge of Multi-Room Chat State

A multi-room chat application presents several state management challenges:
  • Global State: User authentication status, current user information.
  • Collection State: A dynamic list of available chat rooms.
  • Per-Instance State: Messages belonging to a *specific* chat room, which need to be isolated and updated independently.
  • Real-time Updates: Receiving new messages or room updates seamlessly.
  • Performance: Ensuring only necessary UI components rebuild when state changes.
  • Scalability: Maintaining a clean codebase as features grow.
Traditional approaches can quickly lead to boilerplate, tight coupling, and difficult-to-debug issues. This is where Riverpod shines.

Introducing Riverpod for Robust State Management

Riverpod is a powerful, compile-time safe, and testable state management library for Flutter. It solves common problems found in other solutions by providing:
  • Compile-time Safety: Catches errors at compile time, not runtime.
  • No `BuildContext` for Providers: Providers are globally accessible, decoupled from the widget tree.
  • Immutability: Encourages immutable state, making updates predictable.
  • Testability: Easy to test individual providers in isolation.
  • Provider Scoping: Control the lifecycle and scope of your state.
These features make Riverpod an ideal candidate for managing the complex, hierarchical state of a multi-room chat application.

Core Riverpod Concepts in Action

To effectively manage our chat application's state, we'll leverage several key Riverpod concepts:

1. Providers

Providers are the core building blocks of Riverpod. They encapsulate a piece of state or logic.
  • Provider: For read-only state or objects that don't change.
  • StateProvider: For simple mutable state (e.g., `int`, `String`, `bool`).
  • NotifierProvider (and its async variants like AsyncNotifierProvider): For complex mutable state, often paired with a custom `Notifier` class. This is ideal for managing collections or objects with methods.
  • .family Modifier: Crucial for per-instance state. It allows a provider to take an argument, creating a unique instance of the provider for each argument value. This will be invaluable for fetching messages for a specific chat room.

2. ProviderScope

Every Riverpod application must be wrapped in a `ProviderScope` at the root of the widget tree. This widget stores the state of all providers.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/screens/chat_room_list_screen.dart'; // Your main screen

void main() {
  runApp(
    // Wrap the entire app in ProviderScope
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Chat App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const ChatRoomListScreen(),
    );
  }
}

3. ConsumerWidget and HookConsumerWidget

Widgets use `ConsumerWidget` or `HookConsumerWidget` (if using `flutter_hooks`) to interact with providers. They provide a `WidgetRef` object (`ref`) which allows reading, watching, and listening to providers.

Data Modeling for Chat Entities

First, let's define our basic data models. These will be immutable for better state management practices.

// lib/models/user.dart
class User {
  final String id;
  final String name;

  User({required this.id, required this.name});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'],
        name: json['name'],
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
      };
}

// lib/models/chat_room.dart
class ChatRoom {
  final String id;
  final String name;

  ChatRoom({required this.id, required this.name});

  factory ChatRoom.fromJson(Map<String, dynamic> json) => ChatRoom(
        id: json['id'],
        name: json['name'],
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
      };
}

// lib/models/message.dart
class Message {
  final String id;
  final String roomId;
  final String userId;
  final String userName;
  final String text;
  final DateTime timestamp;

  Message({
    required this.id,
    required this.roomId,
    required this.userId,
    required this.userName,
    required this.text,
    required this.timestamp,
  });

  factory Message.fromJson(Map<String, dynamic> json) => Message(
        id: json['id'],
        roomId: json['roomId'],
        userId: json['userId'],
        userName: json['userName'],
        text: json['text'],
        timestamp: DateTime.parse(json['timestamp']),
      );

  Map<String, dynamic> toJson() => {
        'id': id,
        'roomId': roomId,
        'userId': userId,
        'userName': userName,
        'text': text,
        'timestamp': timestamp.toIso8601String(),
      };
}

Implementing Riverpod Providers for Chat Logic

1. User Authentication Provider

We'll use a `StateProvider` for a simple current user, or `NotifierProvider` for more complex auth logic.

// lib/providers/auth_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/models/user.dart';

final currentUserProvider = StateProvider<User?>((ref) => null); // Initially no user

// Example usage to login/logout
// ref.read(currentUserProvider.notifier).state = User(id: 'user1', name: 'Alice');
// ref.read(currentUserProvider.notifier).state = null;

2. Chat Rooms Provider

This provider will fetch and manage the list of available chat rooms. We use `AsyncNotifierProvider` because fetching data is an asynchronous operation.

// lib/providers/chat_rooms_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/models/chat_room.dart';
import 'package:your_app/services/api_service.dart'; // A mock API service

// Mock API Service (replace with actual backend calls)
class ApiService {
  Future<List<ChatRoom>> fetchChatRooms() async {
    await Future.delayed(const Duration(seconds: 1)); // Simulate network delay
    return [
      ChatRoom(id: 'room1', name: 'General Chat'),
      ChatRoom(id: 'room2', name: 'Flutter Devs'),
      ChatRoom(id: 'room3', name: 'Dart Enthusiasts'),
    ];
  }
}

final apiServiceProvider = Provider((ref) => ApiService());

class ChatRoomsNotifier extends AsyncNotifier<List<ChatRoom>> {
  @override
  Future<List<ChatRoom>> build() async {
    return ref.read(apiServiceProvider).fetchChatRooms();
  }

  // You might add methods here to create, update, or delete rooms
  // For simplicity, we'll keep it read-only for now.
}

final chatRoomsProvider = AsyncNotifierProvider<ChatRoomsNotifier, List<ChatRoom>>(
  () => ChatRoomsNotifier(),
);

3. Messages Provider (Per Room)

This is the most critical part for a multi-room chat. We'll use `AsyncNotifierProvider.family` to create a distinct state for messages in each chat room, identified by `roomId`.

// lib/providers/messages_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/models/message.dart';
import 'package:your_app/models/user.dart';
import 'package:your_app/providers/auth_provider.dart';
import 'package:your_app/services/api_service.dart'; // Re-use the mock API service

// Extended Mock API Service for messages
class ApiService {
  // ... (previous fetchChatRooms method)

  Future<List<Message>> fetchMessages(String roomId) async {
    await Future.delayed(const Duration(milliseconds: 500)); // Simulate network delay
    // Return mock messages based on roomId
    if (roomId == 'room1') {
      return [
        Message(id: 'm1', roomId: 'room1', userId: 'user1', userName: 'Alice', text: 'Hello everyone!', timestamp: DateTime.now().subtract(const Duration(minutes: 5))),
        Message(id: 'm2', roomId: 'room1', userId: 'user2', userName: 'Bob', text: 'Hi Alice!', timestamp: DateTime.now().subtract(const Duration(minutes: 4))),
      ];
    } else if (roomId == 'room2') {
      return [
        Message(id: 'm3', roomId: 'room2', userId: 'user3', userName: 'Charlie', text: 'Flutter 3.0 is great!', timestamp: DateTime.now().subtract(const Duration(minutes: 7))),
      ];
    }
    return [];
  }

  Future<Message> sendMessage(String roomId, String userId, String userName, String text) async {
    await Future.delayed(const Duration(milliseconds: 300));
    final newMessage = Message(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      roomId: roomId,
      userId: userId,
      userName: userName,
      text: text,
      timestamp: DateTime.now(),
    );
    // In a real app, this would send to a backend and potentially a WebSocket
    return newMessage;
  }
}

final apiServiceProvider = Provider((ref) => ApiService()); // Ensure this is defined once

class MessagesNotifier extends AsyncNotifier<List<Message>> {
  late String _roomId;

  @override
  Future<List<Message>> build(String roomId) async {
    _roomId = roomId;
    // Simulate real-time updates by periodically refetching
    // In a real app, this would connect to a WebSocket.
    ref.onDispose(() {
      // Cleanup any subscriptions
    });
    return ref.read(apiServiceProvider).fetchMessages(_roomId);
  }

  Future<void> sendMessage(String text) async {
    final currentUser = ref.read(currentUserProvider);
    if (currentUser == null) {
      // Handle not logged in error
      return;
    }

    state = const AsyncValue.loading(); // Show loading state briefly

    try {
      final newMessage = await ref.read(apiServiceProvider).sendMessage(
            _roomId,
            currentUser.id,
            currentUser.name,
            text,
          );
      state = AsyncValue.data([
        ...?state.value, // Keep existing messages
        newMessage, // Add the new message
      ]);
    } catch (e) {
      state = AsyncValue.error('Failed to send message: $e', StackTrace.current);
    }
  }

  // Example for simulating a real-time incoming message
  void addIncomingMessage(Message message) {
    if (message.roomId != _roomId) return; // Only add if it's for this room
    state = AsyncValue.data([
      ...?state.value,
      message,
    ]);
  }
}

final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier, List<Message>, String>(
  () => MessagesNotifier(),
);

Integrating Riverpod with Flutter UI

1. Chat Room List Screen

This screen will display the list of available chat rooms.

// lib/screens/chat_room_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/providers/auth_provider.dart';
import 'package:your_app/providers/chat_rooms_provider.dart';
import 'package:your_app/screens/chat_screen.dart';

class ChatRoomListScreen extends ConsumerWidget {
  const ChatRoomListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final chatRoomsAsyncValue = ref.watch(chatRoomsProvider);
    final currentUser = ref.watch(currentUserProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Chat Rooms'),
        actions: [
          if (currentUser == null)
            IconButton(
              icon: const Icon(Icons.login),
              onPressed: () {
                // Simulate login
                ref.read(currentUserProvider.notifier).state =
                    User(id: 'user4', name: 'David');
              },
            )
          else
            Row(
              children: [
                Text(currentUser.name),
                IconButton(
                  icon: const Icon(Icons.logout),
                  onPressed: () {
                    // Simulate logout
                    ref.read(currentUserProvider.notifier).state = null;
                  },
                ),
              ],
            ),
        ],
      ),
      body: chatRoomsAsyncValue.when(
        data: (rooms) {
          return ListView.builder(
            itemCount: rooms.length,
            itemBuilder: (context, index) {
              final room = rooms[index];
              return ListTile(
                title: Text(room.name),
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute(
                      builder: (context) => ChatScreen(roomId: room.id, roomName: room.name),
                    ),
                  );
                },
              );
            },
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(child: Text('Error: $error')),
      ),
    );
  }
}

2. Chat Screen (Per Room)

This screen will display messages for a specific room and allow sending new messages. It heavily relies on the `messagesProvider.family`.

// lib/screens/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:your_app/providers/messages_provider.dart';

class ChatScreen extends ConsumerStatefulWidget {
  final String roomId;
  final String roomName;

  const ChatScreen({super.key, required this.roomId, required this.roomName});

  @override
  ConsumerState<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends ConsumerState<ChatScreen> {
  final TextEditingController _messageController = TextEditingController();

  void _sendMessage() {
    if (_messageController.text.trim().isEmpty) return;
    ref.read(messagesProvider(widget.roomId).notifier).sendMessage(
          _messageController.text.trim(),
        );
    _messageController.clear();
  }

  @override
  Widget build(BuildContext context) {
    // Watch the specific messagesProvider for this roomId
    final messagesAsyncValue = ref.watch(messagesProvider(widget.roomId));

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.roomName),
      ),
      body: Column(
        children: [
          Expanded(
            child: messagesAsyncValue.when(
              data: (messages) {
                return ListView.builder(
                  reverse: true, // Show latest messages at the bottom
                  itemCount: messages.length,
                  itemBuilder: (context, index) {
                    final message = messages[messages.length - 1 - index]; // Display in correct order
                    return ListTile(
                      title: Text(message.userName),
                      subtitle: Text(message.text),
                      trailing: Text(
                        '${message.timestamp.hour}:${message.timestamp.minute}',
                      ),
                    );
                  },
                );
              },
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (error, stack) => Center(child: Text('Error: $error')),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _messageController,
                    decoration: const InputDecoration(
                      hintText: 'Enter your message...',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _sendMessage(),
                  ),
                ),
                IconButton(
                  icon: const Icon(Icons.send),
                  onPressed: _sendMessage,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

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

Benefits of Using Riverpod for Multi-Room Chat

  • Clear Separation of Concerns: Providers encapsulate state and logic, keeping UI widgets lean and focused on presentation.
  • Scalability with `.family`: Easily manage per-room state without boilerplate. Each chat screen automatically gets its own isolated message stream.
  • Real-time Readiness: While this example uses mock API calls, Riverpod's `AsyncNotifierProvider` can easily integrate with WebSockets or real-time databases (like Firebase, Supabase) to push updates directly into the `Notifier`'s state.
  • Performance: Only widgets that `watch` a specific provider will rebuild when that provider's state changes, minimizing unnecessary UI updates.
  • Testability: Each provider can be tested independently, ensuring the reliability of your state logic.
  • Developer Experience: Compile-time safety catches many common errors early, improving productivity.

Conclusion

Flutter and Riverpod together provide an exceptionally powerful and ergonomic toolkit for building complex applications like multi-room chat. By leveraging Riverpod's providers, especially the `.family` modifier for per-instance state, developers can create a highly scalable, maintainable, and performant application. This approach simplifies the complexities of real-time state management, allowing you to focus more on building features and less on debugging state-related issues.

Related Articles

Aug 02, 2026

Building a Flutter Notification Center with Grouping & Actions

Learn to create a sophisticated in-app notification center in Flutter, featuring grouping, swipe-to-dismiss, custom icons, and actionable buttons for enhanced u

Aug 01, 2026

Flutter Layout Tips: Using FractionallySizedBox, AspectRatio, ConstrainedBox for Adaptive UI

Master Flutter's FractionallySizedBox, AspectRatio, and ConstrainedBox to build truly adaptive UIs. Learn how to control widget sizing, maintain proportions, an

Aug 01, 2026

Flutter Product Quick View: Animation, Related Items, Badges

Learn to create a dynamic product quick view widget in Flutter, incorporating smooth animations, relevant related items, and eye-catching promo badges for an en