Skip to content

Collaborative Features

Build powerful collaborative applications with InstantDB’s real-time synchronization, presence system, and conflict resolution. Create experiences like shared whiteboards, collaborative editors, multiplayer games, and team workspaces.

Complete Collaborative Editor

Here’s a full example of a collaborative text editor with cursors, typing indicators, and presence:

class CollaborativeEditor extends StatefulWidget {
@override
State<CollaborativeEditor> createState() => _CollaborativeEditorState();
}
class _CollaborativeEditorState extends State<CollaborativeEditor> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
InstantRoom? _room;
String? _documentId;
Timer? _typingTimer;
@override
void initState() {
super.initState();
_documentId = 'doc-${widget.documentId}';
_initializeCollaboration();
}
void _initializeCollaboration() {
final db = InstantProvider.of(context);
final currentUser = db.auth.currentUser.value;
// Join collaboration room
_room = db.presence.joinRoom(_documentId!, initialPresence: {
'userName': currentUser?.email ?? 'Anonymous',
'status': 'editing',
'color': _generateUserColor(currentUser?.id ?? 'anonymous'),
});
// Listen to document changes
_subscribeToDocument();
// Handle text selection changes for cursor position
_controller.addListener(_updateCursorPosition);
}
void _subscribeToDocument() {
final db = InstantProvider.of(context);
// Subscribe to document content changes
db.subscribeQuery({
'documents': {
'where': {'id': _documentId},
}
}).stream.listen((result) {
final documents = result.data?['documents'] as List? ?? [];
if (documents.isNotEmpty) {
final document = documents.first as Map<String, dynamic>;
final content = document['content'] as String? ?? '';
// Update content if different (avoid cursor jumps)
if (_controller.text != content) {
final selection = _controller.selection;
_controller.text = content;
_controller.selection = selection;
}
}
});
}
void _updateCursorPosition() {
final selection = _controller.selection;
if (selection.isValid && _room != null) {
_room!.updateCursor(
x: selection.baseOffset.toDouble(),
y: 0, // For text, we use line-based positioning
);
}
}
void _handleTextChange(String text) {
// Update document with debouncing
_typingTimer?.cancel();
_room?.setTyping(true);
_typingTimer = Timer(const Duration(milliseconds: 500), () async {
await _saveDocument(text);
_room?.setTyping(false);
});
}
Future<void> _saveDocument(String content) async {
final db = InstantProvider.of(context);
await db.transact([
db.update(_documentId!, {
'content': content,
'lastModified': DateTime.now().millisecondsSinceEpoch,
}),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Collaborative Editor'),
actions: [
// Show connected users
UserAvatars(room: _room!),
const SizedBox(width: 16),
],
),
body: Column(
children: [
// Connection status
ConnectionStatusBanner(),
// Typing indicators
TypingIndicatorBanner(room: _room!),
// Editor with cursor overlay
Expanded(
child: Stack(
children: [
// Main text editor
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
expands: true,
onChanged: _handleTextChange,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Start typing...',
),
),
),
// Collaborative cursors overlay
CursorOverlay(room: _room!),
],
),
),
],
),
);
}
}

Real-time Whiteboard

Create a collaborative drawing canvas:

class CollaborativeWhiteboard extends StatefulWidget {
@override
State<CollaborativeWhiteboard> createState() => _CollaborativeWhiteboardState();
}
class _CollaborativeWhiteboardState extends State<CollaborativeWhiteboard> {
InstantRoom? _room;
final List<DrawingPoint> _points = [];
String? _currentStroke;
@override
void initState() {
super.initState();
_initializeWhiteboard();
}
void _initializeWhiteboard() {
final db = InstantProvider.of(context);
_room = db.presence.joinRoom('whiteboard', initialPresence: {
'userName': 'Artist ${DateTime.now().millisecondsSinceEpoch % 1000}',
'tool': 'pen',
'color': '#000000',
});
// Subscribe to drawing strokes
db.subscribeQuery({
'strokes': {
'where': {'whiteboardId': 'main'},
'orderBy': {'createdAt': 'asc'},
}
}).stream.listen((result) {
final strokes = result.data?['strokes'] as List? ?? [];
setState(() {
_points.clear();
_points.addAll(strokes.map((s) => DrawingPoint.fromJson(s)));
});
});
}
void _handlePanStart(DragStartDetails details) {
_currentStroke = db.id();
_room?.setPresence({'status': 'drawing'});
final point = DrawingPoint(
id: _currentStroke!,
x: details.localPosition.dx,
y: details.localPosition.dy,
isStart: true,
);
_addPoint(point);
}
void _handlePanUpdate(DragUpdateDetails details) {
if (_currentStroke == null) return;
final point = DrawingPoint(
id: _currentStroke!,
x: details.localPosition.dx,
y: details.localPosition.dy,
isStart: false,
);
_addPoint(point);
// Update cursor position for others
_room?.updateCursor(
x: details.localPosition.dx,
y: details.localPosition.dy,
);
}
void _handlePanEnd(DragEndDetails details) {
_currentStroke = null;
_room?.setPresence({'status': 'idle'});
}
Future<void> _addPoint(DrawingPoint point) async {
final db = InstantProvider.of(context);
await db.transact([
...db.create('points', {
'id': db.id(),
'strokeId': point.id,
'x': point.x,
'y': point.y,
'isStart': point.isStart,
'whiteboardId': 'main',
'createdAt': DateTime.now().millisecondsSinceEpoch,
}),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Collaborative Whiteboard'),
actions: [
UserAvatars(room: _room!),
],
),
body: Stack(
children: [
// Drawing canvas
GestureDetector(
onPanStart: _handlePanStart,
onPanUpdate: _handlePanUpdate,
onPanEnd: _handlePanEnd,
child: CustomPaint(
painter: WhiteboardPainter(_points),
size: Size.infinite,
),
),
// Collaborative cursors
CursorOverlay(room: _room!),
// Reactions overlay
ReactionsOverlay(room: _room!),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _clearCanvas,
child: const Icon(Icons.clear),
),
);
}
}

Team Chat with Presence

Build a team chat with rich presence information:

class TeamChat extends StatefulWidget {
final String teamId;
const TeamChat({super.key, required this.teamId});
@override
State<TeamChat> createState() => _TeamChatState();
}
class _TeamChatState extends State<TeamChat> {
final TextEditingController _messageController = TextEditingController();
InstantRoom? _room;
StreamSubscription? _chatSubscription;
final List<ChatMessage> _messages = [];
@override
void initState() {
super.initState();
_initializeChat();
}
void _initializeChat() {
final db = InstantProvider.of(context);
final currentUser = db.auth.currentUser.value;
// Join team room
_room = db.presence.joinRoom('team-${widget.teamId}', initialPresence: {
'userName': currentUser?.email ?? 'Anonymous',
'status': 'online',
'avatar': currentUser?.metadata?['avatar'],
'lastSeen': DateTime.now().millisecondsSinceEpoch,
});
// Subscribe to chat messages
_chatSubscription = _room!.subscribeTopic('messages').listen((data) {
final message = ChatMessage.fromJson(data);
setState(() {
_messages.add(message);
_messages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
});
});
// Load existing messages
_loadChatHistory();
// Update typing status
_messageController.addListener(_handleTyping);
}
Timer? _typingTimer;
void _handleTyping() {
_room?.setTyping(true);
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 2), () {
_room?.setTyping(false);
});
}
Future<void> _loadChatHistory() async {
final db = InstantProvider.of(context);
final result = await db.queryOnce({
'messages': {
'where': {'teamId': widget.teamId},
'orderBy': {'timestamp': 'asc'},
'limit': 100,
}
});
final messages = result.data?['messages'] as List? ?? [];
setState(() {
_messages.clear();
_messages.addAll(messages.map((m) => ChatMessage.fromJson(m)));
});
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) return;
final db = InstantProvider.of(context);
final currentUser = db.auth.currentUser.value;
final message = {
'id': db.id(),
'teamId': widget.teamId,
'userId': currentUser?.id ?? 'anonymous',
'userName': currentUser?.email ?? 'Anonymous',
'text': text,
'timestamp': DateTime.now().millisecondsSinceEpoch,
};
// Save to database
await db.transact([
...db.create('messages', message),
]);
// Broadcast via presence
await _room!.publishTopic('messages', message);
_messageController.clear();
_room?.setTyping(false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Team ${widget.teamId}'),
actions: [
// Team presence indicators
Watch((context) {
final presence = _room?.getPresence().value ?? {};
final onlineCount = presence.values
.where((p) => p.data['status'] == 'online')
.length;
return Padding(
padding: const EdgeInsets.all(8.0),
child: Chip(
label: Text('$onlineCount online'),
avatar: const Icon(Icons.people, size: 16),
),
);
}),
],
),
body: Column(
children: [
// Messages list
Expanded(
child: ListView.builder(
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return MessageBubble(
message: message,
isOwn: message.userId ==
InstantProvider.of(context).auth.currentUser.value?.id,
);
},
),
),
// Typing indicators
TypingIndicator(room: _room!),
// Message input
Container(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _sendMessage,
icon: const Icon(Icons.send),
),
],
),
),
],
),
);
}
}

Collaborative Task Management

Build a shared task board with real-time updates:

class CollaborativeTaskBoard extends StatefulWidget {
@override
State<CollaborativeTaskBoard> createState() => _CollaborativeTaskBoardState();
}
class _CollaborativeTaskBoardState extends State<CollaborativeTaskBoard> {
InstantRoom? _room;
@override
void initState() {
super.initState();
_initializeTaskBoard();
}
void _initializeTaskBoard() {
final db = InstantProvider.of(context);
final currentUser = db.auth.currentUser.value;
_room = db.presence.joinRoom('task-board', initialPresence: {
'userName': currentUser?.email ?? 'Team Member',
'status': 'viewing',
'currentColumn': null,
});
}
Future<void> _moveTask(String taskId, String toColumn) async {
final db = InstantProvider.of(context);
// Optimistic update with presence
_room?.setPresence({
'status': 'moving_task',
'taskId': taskId,
'toColumn': toColumn,
});
// Send reaction for visual feedback
await _room?.sendReaction('📋', metadata: {
'action': 'task_moved',
'taskId': taskId,
'column': toColumn,
});
await db.transact([
db.update(taskId, {
'status': toColumn,
'updatedAt': DateTime.now().millisecondsSinceEpoch,
}),
]);
_room?.setPresence({'status': 'viewing'});
}
@override
Widget build(BuildContext context) {
final db = InstantProvider.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Task Board'),
actions: [
UserAvatars(room: _room!),
IconButton(
onPressed: _addNewTask,
icon: const Icon(Icons.add),
),
],
),
body: Row(
children: [
// Task columns
Expanded(
child: InstantBuilder(
query: {
'tasks': {
'where': {'boardId': 'main'},
'orderBy': {'createdAt': 'desc'},
}
},
builder: (context, result) {
final tasks = result.data?['tasks'] as List? ?? [];
return Row(
children: [
TaskColumn(
title: 'To Do',
status: 'todo',
tasks: tasks.where((t) => t['status'] == 'todo').toList(),
onTaskMoved: _moveTask,
room: _room!,
),
TaskColumn(
title: 'In Progress',
status: 'inprogress',
tasks: tasks.where((t) => t['status'] == 'inprogress').toList(),
onTaskMoved: _moveTask,
room: _room!,
),
TaskColumn(
title: 'Done',
status: 'done',
tasks: tasks.where((t) => t['status'] == 'done').toList(),
onTaskMoved: _moveTask,
room: _room!,
),
],
);
},
),
),
],
),
);
}
}

Conflict Resolution

InstantDB automatically handles conflicts, but you can implement custom resolution:

class ConflictAwareDocument extends StatefulWidget {
@override
State<ConflictAwareDocument> createState() => _ConflictAwareDocumentState();
}
class _ConflictAwareDocumentState extends State<ConflictAwareDocument> {
final TextEditingController _controller = TextEditingController();
String? _localVersion;
String? _serverVersion;
void _handleConflict(String localContent, String serverContent) {
if (localContent == serverContent) return;
// Show conflict resolution UI
showDialog(
context: context,
builder: (context) => ConflictResolutionDialog(
localVersion: localContent,
serverVersion: serverContent,
onResolved: _resolveConflict,
),
);
}
Future<void> _resolveConflict(String resolvedContent) async {
final db = InstantProvider.of(context);
await db.transact([
db.update('document-id', {
'content': resolvedContent,
'lastModified': DateTime.now().millisecondsSinceEpoch,
'resolvedBy': db.auth.currentUser.value?.id,
}),
]);
_controller.text = resolvedContent;
}
@override
Widget build(BuildContext context) {
return InstantBuilder(
query: {'documents': {'where': {'id': 'document-id'}}},
builder: (context, result) {
final documents = result.data?['documents'] as List? ?? [];
if (documents.isNotEmpty) {
final document = documents.first as Map<String, dynamic>;
final serverContent = document['content'] as String? ?? '';
// Check for conflicts
if (_localVersion != null &&
_localVersion != serverContent &&
_controller.text != serverContent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConflict(_controller.text, serverContent);
});
}
}
return TextField(
controller: _controller,
onChanged: (text) => _localVersion = text,
);
},
);
}
}

Performance Optimization

Optimize collaborative features for large teams:

class OptimizedCollaboration {
static const int MAX_CURSORS = 10;
static const Duration PRESENCE_THROTTLE = Duration(milliseconds: 100);
static const Duration TYPING_DEBOUNCE = Duration(milliseconds: 300);
// Throttle cursor updates
static Timer? _cursorTimer;
static void updateCursor(InstantRoom room, double x, double y) {
_cursorTimer?.cancel();
_cursorTimer = Timer(PRESENCE_THROTTLE, () {
room.updateCursor(x: x, y: y);
});
}
// Limit displayed cursors
static List<MapEntry<String, dynamic>> getLimitedCursors(
Map<String, dynamic> allCursors,
) {
final entries = allCursors.entries.toList();
entries.sort((a, b) {
final aTime = a.value.data['lastUpdate'] ?? 0;
final bTime = b.value.data['lastUpdate'] ?? 0;
return bTime.compareTo(aTime);
});
return entries.take(MAX_CURSORS).toList();
}
// Batch presence updates
static void batchPresenceUpdate(
InstantRoom room,
Map<String, dynamic> updates,
) {
Timer(PRESENCE_THROTTLE, () {
room.setPresence(updates);
});
}
}

Best Practices

1. Handle Connection States

Always show connection status in collaborative apps:

Widget buildConnectionAwareUI() {
return Column(
children: [
ConnectionStatusBanner(),
// Your collaborative UI
],
);
}

2. Provide Visual Feedback

Show user actions with reactions and animations:

void _showCollaborativeAction(String action, String user) {
_room?.sendReaction('✨', metadata: {
'action': action,
'user': user,
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
}

3. Handle Graceful Degradation

Ensure your app works offline:

Widget buildOfflineCapableFeature() {
return Watch((context) {
final isOnline = db.syncEngine?.connectionStatus.value ?? false;
return Column(
children: [
if (!isOnline) OfflineIndicator(),
// Feature works regardless of connection
YourFeature(),
],
);
});
}

4. Clean Up Resources

Always clean up presence and subscriptions:

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

Next Steps

Explore more collaborative patterns: