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 timePresence 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:
- Collaborative Features - Complete collaboration patterns
- Real-time Sync - Understanding data synchronization
- Flutter Widgets - Reactive UI patterns
- Advanced Topics - Optimizing presence performance