Skip to main content
AppFlowy Editor supports collaborative editing through its transaction-based architecture. This guide shows you how to implement real-time collaboration between multiple editor instances.

How Collaboration Works

AppFlowy Editor uses a transaction stream to broadcast changes. Each transaction represents a change to the document, which can be applied to other editor instances to keep them synchronized.

Transaction Flow

  1. User makes an edit in Editor A
  2. Editor A creates a transaction
  3. Transaction is broadcast via transactionStream
  4. Editor B receives the transaction
  5. Editor B applies the transaction with isRemote: true
  6. Both editors are now synchronized

Basic Collaboration Setup

Here’s a simple example with two synchronized editors:
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';

class CollabEditor extends StatefulWidget {
  const CollabEditor({super.key});

  @override
  State<CollabEditor> createState() => _CollabEditorState();
}

class _CollabEditorState extends State<CollabEditor> {
  // Create two separate editor states
  final EditorState editorStateA =
      EditorState(document: Document.blank(withInitialText: true));
  final EditorState editorStateB =
      EditorState(document: Document.blank(withInitialText: true));

  @override
  void initState() {
    super.initState();

    // Listen to changes from Editor A and apply to Editor B
    editorStateA.transactionStream.listen((event) {
      WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
        if (event.$1 == TransactionTime.before) {
          editorStateB.apply(event.$2, isRemote: true);
        }
      });
    });

    // Listen to changes from Editor B and apply to Editor A
    editorStateB.transactionStream.listen((event) {
      WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
        if (event.$1 == TransactionTime.before) {
          editorStateA.apply(event.$2, isRemote: true);
        }
      });
    });
  }

  @override
  void dispose() {
    editorStateA.dispose();
    editorStateB.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          Expanded(
            child: AppFlowyEditor(
              editorState: editorStateA,
            ),
          ),
          const VerticalDivider(),
          Expanded(
            child: AppFlowyEditor(
              editorState: editorStateB,
            ),
          ),
        ],
      ),
    );
  }
}

Transaction Stream

The transactionStream emits events for every change:
import 'package:appflowy_editor/appflowy_editor.dart';

final editorState = EditorState(document: Document.blank());

editorState.transactionStream.listen((event) {
  final time = event.$1;        // TransactionTime: before or after
  final transaction = event.$2;  // The actual transaction
  final options = event.$3;      // Apply options
  
  print('Transaction at $time');
  print('Operations: ${transaction.operations}');
});

Transaction Time

  • TransactionTime.before: Before the transaction is applied locally
  • TransactionTime.after: After the transaction is applied locally
For collaboration, listen to TransactionTime.before to capture changes before they’re applied.

Remote Transactions

When applying transactions from other users, use the isRemote flag:
import 'package:appflowy_editor/appflowy_editor.dart';

// Apply a transaction from a remote user
editorState.apply(
  transaction,
  isRemote: true,  // This prevents creating a new undo/redo entry
);

ApplyOptions

const ApplyOptions(
  recordUndo: true,    // Record in undo stack
  recordRedo: false,   // Record in redo stack
  inMemoryUpdate: false, // In-memory only (no persistence)
);
For remote transactions:
editorState.apply(
  transaction,
  isRemote: true,
  options: const ApplyOptions(
    recordUndo: false,  // Don't record remote changes in undo
    recordRedo: false,
  ),
);

Network Integration

WebSocket Example

Here’s how to integrate with WebSocket for real-time collaboration:
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'dart:convert';

class NetworkedEditor extends StatefulWidget {
  final String documentId;
  final String userId;

  const NetworkedEditor({
    required this.documentId,
    required this.userId,
  });

  @override
  State<NetworkedEditor> createState() => _NetworkedEditorState();
}

class _NetworkedEditorState extends State<NetworkedEditor> {
  late EditorState editorState;
  late WebSocketChannel channel;

  @override
  void initState() {
    super.initState();
    
    // Initialize editor
    editorState = EditorState(document: Document.blank());
    
    // Connect to WebSocket
    channel = WebSocketChannel.connect(
      Uri.parse('ws://your-server.com/collab/${widget.documentId}'),
    );
    
    // Listen to local changes and broadcast
    editorState.transactionStream.listen((event) {
      if (event.$1 == TransactionTime.before) {
        final message = {
          'type': 'transaction',
          'userId': widget.userId,
          'transaction': event.$2.toJson(),
        };
        channel.sink.add(jsonEncode(message));
      }
    });
    
    // Listen to remote changes
    channel.stream.listen((message) {
      final data = jsonDecode(message);
      
      // Ignore our own messages
      if (data['userId'] == widget.userId) return;
      
      if (data['type'] == 'transaction') {
        final transaction = Transaction.fromJson(data['transaction']);
        editorState.apply(transaction, isRemote: true);
      }
    });
  }

  @override
  void dispose() {
    channel.sink.close();
    editorState.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AppFlowyEditor(
      editorState: editorState,
    );
  }
}

Conflict Resolution

For production collaborative editing, implement Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs).

Simple Last-Write-Wins

import 'package:appflowy_editor/appflowy_editor.dart';

class CollabController {
  final EditorState editorState;
  int _localVersion = 0;
  int _remoteVersion = 0;

  CollabController(this.editorState) {
    editorState.transactionStream.listen((event) {
      if (event.$1 == TransactionTime.after) {
        _localVersion++;
      }
    });
  }

  void applyRemoteTransaction(Transaction transaction, int version) {
    if (version > _remoteVersion) {
      editorState.apply(transaction, isRemote: true);
      _remoteVersion = version;
    }
  }
}

Selection Synchronization

Show other users’ cursors and selections:
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';

class RemoteSelection {
  final String userId;
  final String userName;
  final Selection selection;
  final Color color;

  RemoteSelection({
    required this.userId,
    required this.userName,
    required this.selection,
    required this.color,
  });
}

class CollabEditorWithCursors extends StatefulWidget {
  @override
  State<CollabEditorWithCursors> createState() => _CollabEditorWithCursorsState();
}

class _CollabEditorWithCursorsState extends State<CollabEditorWithCursors> {
  late EditorState editorState;
  final Map<String, RemoteSelection> remoteSelections = {};

  @override
  void initState() {
    super.initState();
    editorState = EditorState(document: Document.blank());
    
    // Listen to local selection changes and broadcast
    editorState.selectionNotifier.addListener(() {
      final selection = editorState.selection;
      if (selection != null) {
        broadcastSelection(selection);
      }
    });
  }

  void broadcastSelection(Selection selection) {
    // Send selection to other users
    // Implementation depends on your network layer
  }

  void receiveRemoteSelection(RemoteSelection remoteSelection) {
    setState(() {
      remoteSelections[remoteSelection.userId] = remoteSelection;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        AppFlowyEditor(
          editorState: editorState,
        ),
        // Render remote cursors
        ...remoteSelections.values.map((rs) => RemoteCursor(
          selection: rs.selection,
          color: rs.color,
          userName: rs.userName,
        )),
      ],
    );
  }
}

Presence Awareness

Track active users:
import 'package:appflowy_editor/appflowy_editor.dart';

class UserPresence {
  final String userId;
  final String userName;
  final DateTime lastSeen;
  final bool isActive;

  UserPresence({
    required this.userId,
    required this.userName,
    required this.lastSeen,
    required this.isActive,
  });
}

class PresenceController {
  final Map<String, UserPresence> _users = {};
  Timer? _heartbeatTimer;

  void start() {
    _heartbeatTimer = Timer.periodic(
      const Duration(seconds: 5),
      (_) => sendHeartbeat(),
    );
  }

  void sendHeartbeat() {
    // Send presence update to server
  }

  void updatePresence(UserPresence presence) {
    _users[presence.userId] = presence;
  }

  List<UserPresence> getActiveUsers() {
    final now = DateTime.now();
    return _users.values
        .where((u) => now.difference(u.lastSeen).inSeconds < 30)
        .toList();
  }

  void dispose() {
    _heartbeatTimer?.cancel();
  }
}

Best Practices

  1. Use Remote Flag: Always set isRemote: true for transactions from other users
  2. Transaction Timing: Listen to TransactionTime.before for broadcasting changes
  3. Debounce Selection: Don’t broadcast selection changes too frequently
  4. Handle Disconnections: Implement reconnection logic with state sync
  5. Version Control: Track document versions for conflict detection
  6. User Identification: Assign unique IDs and colors to each user
  7. Error Handling: Handle network errors and transaction failures gracefully
  8. Optimize Bandwidth: Send only necessary data, compress if needed

Performance Considerations

  • Throttle Updates: Limit broadcast frequency for rapid typing
  • Batch Operations: Group multiple small changes into single transactions
  • Lazy Loading: Load document sections on demand for large documents
  • Memory Management: Clean up disposed editor states and listeners

Security

  • Authentication: Verify user identity before allowing edits
  • Authorization: Check user permissions for document access
  • Validation: Validate all incoming transactions
  • Sanitization: Sanitize user input to prevent XSS attacks
  • Rate Limiting: Prevent abuse with rate limits

Build docs developers (and LLMs) love