Flutter InstantDB
Real-time

Presence System

Real-time collaboration with cursors, typing indicators, and reactions

InstantDB's presence system enables real-time collaboration features like cursors, typing indicators, reactions, and user avatars. It's perfect for building collaborative applications.

Room-Based Presence

The modern approach uses room-based APIs for better organization and scoping:

Joining a Room

class CollaborativeEditor extends StatefulWidget {
  @override
  State<CollaborativeEditor> createState() => _CollaborativeEditorState();
}

class _CollaborativeEditorState extends State<CollaborativeEditor> {
  String? _userId;
  String? _userName;
  InstantRoom? _room;
  
  @override
  void initState() {
    super.initState();
    _initializePresence();
  }
  
  void _initializePresence() {
    final db = InstantProvider.of(context);
    final currentUser = db.auth.currentUser.value;
    
    // Use authenticated user or generate anonymous identity
    if (currentUser != null) {
      _userId = currentUser.id;
      _userName = currentUser.email;
    } else {
      _userId = db.getAnonymousUserId();
      _userName = 'Guest ${_userId!.substring(_userId!.length - 4)}';
    }
    
    // Join room with initial presence data
    _room = db.presence.joinRoom('editor-room', initialPresence: {
      'userName': _userName,
      'status': 'editing',
      'avatar': _generateAvatar(_userName!),
    });
  }
}

Basic Presence Operations

// Update user presence
await _room!.setPresence({
  'status': 'typing',
  'lastSeen': DateTime.now().millisecondsSinceEpoch,
});

// Update cursor position
await _room!.updateCursor(x: 100, y: 200);

// Set typing indicator
await _room!.setTyping(true);

// Send a reaction
await _room!.sendReaction('👍', metadata: {
  'x': mouseX,
  'y': mouseY,
  'message': 'Great idea!',
});

Displaying Presence Data

User Avatars

Show all connected users in a room:

class UserAvatars extends StatelessWidget {
  final InstantRoom room;
  
  const UserAvatars({super.key, required this.room});
  
  @override
  Widget build(BuildContext context) {
    return Watch((context) {
      final presence = room.getPresence().value;
      
      final users = presence.entries
          .where((entry) => entry.value.data['status'] == 'online')
          .take(5)
          .toList();
      
      return Row(
        children: [
          ...users.map((entry) {
            final user = entry.value.data;
            return Padding(
              padding: const EdgeInsets.only(right: 8),
              child: CircleAvatar(
                radius: 16,
                backgroundColor: _getUserColor(user['userName']),
                child: Text(
                  _getInitials(user['userName'] ?? '?'),
                  style: const TextStyle(
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
              ),
            );
          }),
          
          if (presence.length > 5)
            CircleAvatar(
              radius: 16,
              backgroundColor: Colors.grey,
              child: Text(
                '+${presence.length - 5}',
                style: const TextStyle(fontSize: 10, color: Colors.white),
              ),
            ),
        ],
      );
    });
  }
}

Live Cursors

Display real-time cursor positions:

class CursorOverlay extends StatelessWidget {
  final InstantRoom room;
  final Widget child;
  
  const CursorOverlay({super.key, required this.room, required this.child});
  
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        child,
        
        // Cursor layer
        Watch((context) {
          final cursors = room.getCursors().value;
          
          return Stack(
            children: cursors.entries.map((entry) {
              final cursor = entry.value;
              final userName = cursor.data['userName'] ?? 'Unknown';
              
              return Positioned(
                left: cursor.x,
                top: cursor.y,
                child: _CursorWidget(
                  userName: userName,
                  color: _getUserColor(userName),
                ),
              );
            }).toList(),
          );
        }),
      ],
    );
  }
}

class _CursorWidget extends StatelessWidget {
  final String userName;
  final Color color;
  
  const _CursorWidget({required this.userName, required this.color});
  
  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Cursor pointer
        CustomPaint(
          size: const Size(20, 20),
          painter: CursorPainter(color: color),
        ),
        
        // User name label
        Container(
          padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
          decoration: BoxDecoration(
            color: color,
            borderRadius: BorderRadius.circular(4),
          ),
          child: Text(
            userName,
            style: const TextStyle(
              fontSize: 12,
              color: Colors.white,
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
      ],
    );
  }
}

Typing Indicators

Show who's currently typing:

class TypingIndicator extends StatelessWidget {
  final InstantRoom room;
  
  const TypingIndicator({super.key, required this.room});
  
  @override
  Widget build(BuildContext context) {
    return Watch((context) {
      final typing = room.getTyping().value;
      
      if (typing.isEmpty) {
        return const SizedBox.shrink();
      }
      
      final typingUsers = typing.entries
          .map((entry) => entry.value.data['userName'] as String? ?? 'Someone')
          .toList();
      
      String text;
      if (typingUsers.length == 1) {
        text = '${typingUsers.first} is typing...';
      } else if (typingUsers.length == 2) {
        text = '${typingUsers.first} and ${typingUsers.last} are typing...';
      } else {
        text = '${typingUsers.length} people are typing...';
      }
      
      return Container(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        decoration: BoxDecoration(
          color: Colors.grey.shade100,
          borderRadius: BorderRadius.circular(16),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            SizedBox(
              width: 16,
              height: 16,
              child: CircularProgressIndicator(
                strokeWidth: 2,
                valueColor: AlwaysStoppedAnimation(Colors.grey.shade600),
              ),
            ),
            const SizedBox(width: 8),
            Text(
              text,
              style: TextStyle(
                fontSize: 12,
                color: Colors.grey.shade600,
                fontStyle: FontStyle.italic,
              ),
            ),
          ],
        ),
      );
    });
  }
}

Reactions

Display floating reactions:

class ReactionsOverlay extends StatelessWidget {
  final InstantRoom room;
  final Widget child;
  
  const ReactionsOverlay({super.key, required this.room, required this.child});
  
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        child,
        
        // Reactions layer
        Watch((context) {
          final reactions = room.getReactions().value;
          
          return Stack(
            children: reactions.map((reaction) {
              final metadata = reaction.data['metadata'] as Map<String, dynamic>?;
              final x = metadata?['x']?.toDouble() ?? 0.0;
              final y = metadata?['y']?.toDouble() ?? 0.0;
              
              return Positioned(
                left: x,
                top: y,
                child: AnimatedReaction(
                  emoji: reaction.data['reaction'] as String? ?? '❤️',
                  onComplete: () {
                    // Reaction animation completed
                  },
                ),
              );
            }).toList(),
          );
        }),
      ],
    );
  }
}

class AnimatedReaction extends StatefulWidget {
  final String emoji;
  final VoidCallback onComplete;
  
  const AnimatedReaction({
    super.key,
    required this.emoji,
    required this.onComplete,
  });
  
  @override
  State<AnimatedReaction> createState() => _AnimatedReactionState();
}

class _AnimatedReactionState extends State<AnimatedReaction>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;
  late Animation<double> _opacityAnimation;
  late Animation<Offset> _positionAnimation;
  
  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    
    _scaleAnimation = Tween<double>(begin: 0.5, end: 1.2).animate(
      CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
    );
    
    _opacityAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.7, 1.0, curve: Curves.easeOut),
      ),
    );
    
    _positionAnimation = Tween<Offset>(
      begin: Offset.zero,
      end: const Offset(0, -50),
    ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
    
    _controller.forward().then((_) => widget.onComplete());
  }
  
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.translate(
          offset: _positionAnimation.value,
          child: Transform.scale(
            scale: _scaleAnimation.value,
            child: Opacity(
              opacity: _opacityAnimation.value,
              child: Text(
                widget.emoji,
                style: const TextStyle(fontSize: 24),
              ),
            ),
          ),
        );
      },
    );
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

Topic-Based Messaging

Use topics for structured communication within rooms:

class ChatRoom extends StatefulWidget {
  @override
  State<ChatRoom> createState() => _ChatRoomState();
}

class _ChatRoomState extends State<ChatRoom> {
  InstantRoom? _room;
  final List<ChatMessage> _messages = [];
  StreamSubscription? _chatSubscription;
  
  @override
  void initState() {
    super.initState();
    _initializeRoom();
  }
  
  void _initializeRoom() {
    final db = InstantProvider.of(context);
    
    _room = db.presence.joinRoom('chat-room', initialPresence: {
      'userName': 'Current User',
      'status': 'online',
    });
    
    // Subscribe to chat messages
    _chatSubscription = _room!.subscribeTopic('chat').listen((data) {
      final message = ChatMessage.fromJson(data);
      setState(() {
        _messages.add(message);
      });
    });
  }
  
  void _sendMessage(String text) {
    if (text.trim().isEmpty) return;
    
    final message = {
      'id': DateTime.now().millisecondsSinceEpoch.toString(),
      'text': text.trim(),
      'userName': 'Current User',
      'timestamp': DateTime.now().millisecondsSinceEpoch,
    };
    
    _room!.publishTopic('chat', message);
  }
}

Advanced Presence Features

Presence with Custom Data

Store custom data in presence:

// Rich presence data
await _room!.setPresence({
  'userName': 'Alice',
  'status': 'editing',
  'currentDocument': documentId,
  'tool': 'text',
  'mood': '😊',
  'location': {
    'section': 'introduction',
    'paragraph': 3,
  },
});

Temporary Presence Events

Send temporary events that don't persist:

// Temporary events (reactions, notifications)
await _room!.sendReaction('🎉', metadata: {
  'achievement': 'Document completed!',
  'x': 200,
  'y': 100,
});

// These disappear after a short time

Presence Cleanup

Always clean up presence when leaving:

@override
void dispose() {
  _room?.setPresence({'status': 'offline'});
  _room = null;
  _chatSubscription?.cancel();
  super.dispose();
}

Best Practices

1. Initialize Presence Early

Set up presence as soon as users enter collaborative spaces:

@override
void initState() {
  super.initState();
  // Initialize presence immediately
  _initializePresence();
}

2. Throttle Updates

Avoid excessive presence updates:

Timer? _cursorTimer;

void _updateCursor(double x, double y) {
  _cursorTimer?.cancel();
  _cursorTimer = Timer(const Duration(milliseconds: 100), () {
    _room?.updateCursor(x: x, y: y);
  });
}

3. Handle Anonymous Users

Provide good defaults for anonymous users:

String _generateGuestName(String userId) {
  return 'Guest ${userId.substring(userId.length - 4)}';
}

4. Cleanup Resources

Always clean up when leaving:

void _leaveRoom() {
  _room?.setPresence({'status': 'offline'});
  db.presence.leaveRoom('room-id');
}

Presence UI Patterns

Status Indicators

Widget buildStatusIndicator(String status) {
  Color color;
  IconData icon;
  
  switch (status) {
    case 'online':
      color = Colors.green;
      icon = Icons.circle;
      break;
    case 'typing':
      color = Colors.blue;
      icon = Icons.edit;
      break;
    case 'away':
      color = Colors.orange;
      icon = Icons.schedule;
      break;
    default:
      color = Colors.grey;
      icon = Icons.circle_outlined;
  }
  
  return Icon(icon, color: color, size: 12);
}

User Count Badge

Widget buildUserCount(int count) {
  return Container(
    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
    decoration: BoxDecoration(
      color: Colors.blue,
      borderRadius: BorderRadius.circular(12),
    ),
    child: Text(
      '$count online',
      style: const TextStyle(color: Colors.white, fontSize: 12),
    ),
  );
}

Reactive Presence Widgets

These widgets are the Flutter equivalents of InstantDB's React presence hooks. Each resolves the client via InstantProvider.of(context), joins the room for you, and rebuilds on change.

PresenceBuilder

Equivalent of React usePresence. Joins the room, publishes initialPresence, rebuilds whenever a peer's presence changes, and leaves on dispose. The InstantRoom handle is passed to the builder so children can call room.setPresence(...).

PresenceBuilder(
  roomId: 'doc-42',
  initialPresence: {'name': 'Alice'},
  builder: (context, room, peers) => Text('${peers.length} online'),
)

TopicListener

Equivalent of React useTopicEffect. A side-effect widget that subscribes to a topic and invokes onEvent for each message, rendering child unchanged.

TopicListener(
  roomId: 'doc-42',
  topic: 'emoji',
  onEvent: (data) => _showFloatingEmoji(data['emoji']),
  child: const Editor(),
)

TypingIndicatorBuilder

Rebuilds with the current set of typing peers keyed by id.

TypingIndicatorBuilder(
  roomId: 'doc-42',
  builder: (context, typing) =>
      typing.isEmpty ? const SizedBox() : Text('${typing.length} typing…'),
)

ReactionsBuilder

Rebuilds with the live list of reactions broadcast in the room.

ReactionsBuilder(
  roomId: 'doc-42',
  builder: (context, reactions) => Wrap(
    children: [for (final r in reactions) Text(r.emoji)],
  ),
)

CursorOverlay

Multiplayer cursor layer — the Flutter equivalent of InstantDB's <Cursors>. Wrap any content; it tracks the local pointer via MouseRegion, publishes it, and paints peers' cursors on top.

CursorOverlay(
  roomId: 'doc-42',
  userName: 'Alice',
  userColor: '#E91E63',
  child: const Canvas(),
)

Provide a cursorBuilder to customize how remote cursors render:

CursorOverlay(
  roomId: 'doc-42',
  cursorBuilder: (context, cursor) => Icon(Icons.navigation, color: Colors.pink),
  child: const Canvas(),
)

Next Steps

Learn more about building collaborative features:

On this page