← Articles

Building a real-time chat feature in Flutter

By Ann Tech · 27 May 2026

Chat is the canonical real-time feature. It touches Firestore listeners, optimistic UI, scroll management, and local state — all in one screen. Here is how to build it correctly.

Firestore data model

chats/{chatId}/
  participants: ['userId1', 'userId2']
  lastMessage: 'Hey!'
  lastMessageAt: Timestamp
  unreadCount: { userId1: 0, userId2: 2 }

chats/{chatId}/messages/{messageId}/
  text: 'Hey!'
  senderId: 'userId1'
  sentAt: Timestamp
  status: 'sent' | 'delivered' | 'read'

Listening to messages

@riverpod
Stream<List<Message>> chatMessages(ChatMessagesRef ref, String chatId) {
  return FirebaseFirestore.instance
      .collection('chats')
      .doc(chatId)
      .collection('messages')
      .orderBy('sentAt', descending: true)
      .limit(50)
      .snapshots()
      .map((snap) => snap.docs
          .map((d) => Message.fromJson({'id': d.id, ...d.data()}))
          .toList());
}

Optimistic message sending

Show the message immediately before the Firestore write completes:

@riverpod
class ChatNotifier extends _$ChatNotifier {
  @override
  List<Message> build(String chatId) => [];

  Future<void> sendMessage(String text) async {
    final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
    final optimisticMessage = Message(
      id: tempId,
      text: text,
      senderId: currentUserId,
      sentAt: DateTime.now(),
      status: MessageStatus.sending,
    );

    // Add to local state immediately
    state = [optimisticMessage, ...state];

    try {
      final ref = await FirebaseFirestore.instance
          .collection('chats')
          .doc(chatId)
          .collection('messages')
          .add({
        'text': text,
        'senderId': currentUserId,
        'sentAt': FieldValue.serverTimestamp(),
        'status': 'sent',
      });

      // Replace temp message with confirmed one
      state = state.map((m) =>
        m.id == tempId ? m.copyWith(id: ref.id, status: MessageStatus.sent) : m
      ).toList();
    } catch (e) {
      // Mark as failed
      state = state.map((m) =>
        m.id == tempId ? m.copyWith(status: MessageStatus.failed) : m
      ).toList();
    }
  }
}

The chat UI

class ChatScreen extends ConsumerStatefulWidget {
  const ChatScreen({super.key, required this.chatId});
  final String chatId;

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

class _ChatScreenState extends ConsumerState<ChatScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();

  @override
  Widget build(BuildContext context) {
    final messagesAsync = ref.watch(chatMessagesProvider(widget.chatId));

    return Scaffold(
      appBar: AppBar(title: const Text('Chat')),
      body: Column(
        children: [
          Expanded(
            child: messagesAsync.when(
              data: (messages) => ListView.builder(
                controller: _scrollController,
                reverse: true, // Newest at bottom
                itemCount: messages.length,
                itemBuilder: (_, i) => MessageBubble(message: messages[i]),
              ),
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (e, _) => Center(child: Text('Error: $e')),
            ),
          ),
          MessageInputBar(
            controller: _controller,
            onSend: () {
              final text = _controller.text.trim();
              if (text.isEmpty) return;
              ref.read(chatNotifierProvider(widget.chatId).notifier)
                  .sendMessage(text);
              _controller.clear();
            },
          ),
        ],
      ),
    );
  }
}

Message bubble with status

class MessageBubble extends StatelessWidget {
  const MessageBubble({super.key, required this.message});
  final Message message;

  @override
  Widget build(BuildContext context) {
    final isMe = message.senderId == currentUserId;
    final cs = Theme.of(context).colorScheme;

    return Align(
      alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
        constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75),
        decoration: BoxDecoration(
          color: isMe ? cs.primary : cs.surfaceVariant,
          borderRadius: BorderRadiusDirectional.only(
            topStart: const Radius.circular(16),
            topEnd: const Radius.circular(16),
            bottomStart: isMe ? const Radius.circular(16) : Radius.zero,
            bottomEnd: isMe ? Radius.zero : const Radius.circular(16),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            Text(message.text,
                style: TextStyle(color: isMe ? cs.onPrimary : cs.onSurface)),
            const SizedBox(height: 4),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  _formatTime(message.sentAt),
                  style: TextStyle(
                    fontSize: 11,
                    color: (isMe ? cs.onPrimary : cs.onSurface).withOpacity(0.6),
                  ),
                ),
                if (isMe) ...[
                  const SizedBox(width: 4),
                  _StatusIcon(status: message.status),
                ],
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Typing indicator

// Write to Firestore when user starts/stops typing
void _onTypingChanged(bool isTyping) {
  FirebaseFirestore.instance
      .collection('chats')
      .doc(chatId)
      .update({'typing.$currentUserId': isTyping});
}

// Listen for other user typing
Stream<bool> isOtherUserTyping(String chatId, String otherUserId) {
  return FirebaseFirestore.instance
      .collection('chats')
      .doc(chatId)
      .snapshots()
      .map((snap) => (snap.data()?['typing'] as Map<String, dynamic>?)?[otherUserId] as bool? ?? false);
}

Common pitfalls

Not using reverse: true on the ListView. Chat lists show newest at the bottom. ListView.builder(reverse: true) with orderBy('sentAt', descending: true) is the correct combination — the list starts at the bottom and new items appear there naturally.

Writing server timestamps with client time. Always use FieldValue.serverTimestamp() for sentAt, not DateTime.now(). Client clocks can be wrong; server timestamps are consistent and sortable across all clients.

Opening listeners without closing them. Riverpod closes stream subscriptions automatically when providers are disposed. If you use manual StreamSubscription, always cancel in dispose().

Sign in to like, dislike, or report.

Building a real-time chat feature in Flutter — ANN Tech