In this blog post, I will walk you through the easiest way to create a multi-user live editing experience in a rich text editor.
Here's the Live Preview
Problems in implementing
  Our first solution when editing a document without considering a collaborative
  experience is just a single UPDATE API. Whatever changes the user
  makes we will trigger an UPDATE API which has the entire
  document's content to the server and we store that in the database.
If multiple users update the same document at the same time, then if we use the above approach, there is a chance of data loss or breaking the integrity of the document.
 
In the above image, you can see the final state of the document is different for two users.
Even if you share the same between users using web sockets, the same issue happens.
There are many approaches in which this problem is solved. But the most recommended approach to solve this problem is using a technique called Operational Transformation(OT).
What is Operational Transformation (OT)?
Operational Transformation is a technique used to synchronize and coordinate the actions of multiple users working on a shared document or data structure in a distributed system. The primary goal is to maintain consistency and coherence among the various versions of the document, even when multiple users are making changes simultaneously.
Operational Transformation Principles:
Concurrency Control:
One of the primary objectives of operational transformation is to manage concurrent edits by different users. This involves detecting conflicts and resolving them in a way that maintains the integrity of the document.
Transform Functions
Transform functions are at the core of operational transformation. These functions define how operations performed by one user can be transformed to account for the changes made by another user concurrently.
Idempotent and Commutative Operations:
Idempotent and commutative operations are fundamental concepts in operational transformation. An idempotent operation produces the same result regardless of how many times it is applied, while commutative operations can be applied in any order without changing the result. These properties facilitate the transformation process.
How it works?
 
So consider Google Docs where a single document is being concurrently edited by more than one person. If a user makes a change,
Instead of sending the entire data to the server to store in DB, We only send and receive that particular operation.
In the code sandbox below, Add any contents to see the operations in the logs.
- Add contents
- Apply styles (bold, italics)
- Remove contents
 
And yes, We won't be sending updates for every keypress. To optimize the workflow the user actions are debounced.
Do the same in the below code sandbox, you can see a debounced (500ms) log with all the actions composed into a single operation.
Operations
Mainly Operational Transform is made up of 3 types of operations
- 
    Insert{insert: "Hello!"}- InsertsHello!at a particular position
- 
    Retain{retain: 5}- Retains 5 characters. (Will make more sense later)
- 
    Delete{delete: 10}- Delete 10 characters.
These 3 operations are used in the overall editing experience.
Insert or delete are straightforward, add or remove contents.
  insert and retaincan have another property called as
  attributes to maintain applied styles to it. (Apply some styles
  like bold, and italics to the above code sandbox to
  see the logs. )
 
  Any formatting or commenting present in the editing text can be persisted
  using extra properties as well. Like the attributes property you
  see in the above image.
So to build this we can create the editor, schema, and operations from scratch. Or we can use some open-source packages that help us achieve this.
I'm using
- Quill - This provides changes as operations and tools to work on Operational Transform.
- React Quill - A Quill component for React. (We will be using this for React)
- 
    quill-delta- We will be using this package from Quill itself to create and maintain the document asdelta(A format that Quill follows to achieve OT)
  As mentioned above quill-delta is the package that we use to do
  the operational transformation. To give a gist of how Operational
  Transformation works, Here's a small Node.js app
To set and run this node app
mkdir ot-node-demo
cd ot-node-demo
npm init -y
npm i quill-deltaCreate a file app.js and paste the code below in that file.
const Delta = require("quill-delta");
const A = new Delta().insert("Something");
const B = new Delta().insert("Something");
console.log("A: ", A.ops);
console.log("B: ", B.ops);
const userAChanges = new Delta().retain(4).insert("one").delete(5);
const userBChanges = new Delta().retain(9).insert("!");
const a_after_changes = A.compose(userAChanges);
const b_after_changes = B.compose(userBChanges);
console.log("A after changes: ", a_after_changes);
console.log("B after changes: ", b_after_changes);
const a_after_transform = a_after_changes.transform(userBChanges, true);
const b_after_transform = b_after_changes.transform(userAChanges, false);
console.info("A after Operational Transform: ", a_after_transform);
console.info("B after Operational Transform", b_after_transform);
const a_final_draft = a_after_changes.compose(a_after_transform);
const b_final_draft = b_after_changes.compose(b_after_transform);
console.info("A's final draft: ", a_final_draft);
console.info("B's final draft: ", b_final_draft);
  The code considers two users A and B who does
  concurrent changes onto a same document at the same time
  (a_after_changes, b_after_changes).
  Instead of applying those changes directly, Those changes are transformed
  against the current changes using the
  transform
  function provided by quill-delta that's where the Operational
  Transformation occurs.
This makes sure that the changes from another remote are applied considering the changes in the current machine. So the intention as well as the integrity is preserved.
node appThe output of that code will be
A:  [ { insert: 'Something' } ]
B:  [ { insert: 'Something' } ]
A after changes:  Delta { ops: [ { insert: 'Someone' } ] }
B after changes:  Delta { ops: [ { insert: 'Something!' } ] }
A after operational transform:  Delta { ops: [ { retain: 16 }, { insert: '!' } ] }
B after operational transform:  Delta { ops: [ { retain: 14 }, { insert: 'one' }, { delete: 5 } ] }
A's final draft:  Delta {
  ops: [ { insert: 'Someone' }, { retain: 9 }, { insert: '!' } ]
}
B's final draft:  Delta {
  ops: [
    { insert: 'Something!' },
    { retain: 4 },
    { insert: 'one' },
    { delete: 5 }
  ]
}
  If you evaluate the operations of the final draft, both the users
  A and B will end up having exact same document. āØ
Let's dive into the actual demo
Let's bring all the things that we learned above into a web app. Here we will be building a Frontend App for a quick intro to OT which mocks the server in the frontend itself. We won't be building a complete system as it requires a lot of time and effort (And resources so it can't be demoed)
This is how the layout of our demo app looks like,
 
In here By default, there will be 2 users (So we can directly go with the demo without any unwanted flow)
  And an Add user button. Consider extra n users joining the same
  room (like more users visiting the same Google doc.)
The component structure for this looks like
<App>
  <AddUser />
  <User />
</App>Where,
  <App /> - Acts as a controller and also as a server in a
  real-world scenario. It holds the data which supposed to be stored on the
  server.
  operations: {delta: Delta, name: string}[] - The temporary
  operations that are being transferred between clients.
- 
    delta- TheDeltaobject fromquill-deltathat has the current change.
- 
    name- User's name who is responsible for the change. (So it doesn't need to be applied for that user)
- 
    content: Delta- The actual content of the document that is centralized in the server.
- 
    users: string[]- Number of users that the current room has.
  <User /> - Acts as individual browsers/users who can view
  and edit the document in the rich text editor.
- 
    If user changes the document, it sends the changes to the
    operationsstate value. (View code for more info)
- If some other user changes the document, it receives the changes and does the OT and composes those changes to get the final data.
I have the runnable code in this Github repository operational-transformation-demo-fe.
Github Repo
Live Demo link
The app's operations transfer is debounced to 2 seconds mocking the turnaround time for the change message to arrive at the client.
As I mentioned above, The primary code for this app is only inside these two files
- 
    App.jsx
    - Acts as a Server and only passes operations and
    initial state to the <User />components (when a new user joins in a room which already has some content)
- User.jsx - Does two things. If there are any changes from the user
// App.jsx
import { useEffect, useRef, useState } from "react";
import Delta from "quill-delta";
import { AddUser } from "./components/AddUser";
import { User } from "./components/User";
import { Footer } from "./components/Footer";
import "./App.css";
function App() {
  // Server state
  const [users, setUsers] = useState(["Peter", "Stewie"]);
  const [operations, setOperations] = useState([]);
  // Frequently updated and not depended for any state to update
  // Used to send initial state when a new user is created.
  const contentRef = useRef(new Delta());
  // Effects
  // Updating Server state
  useEffect(() => {
    if (operations.length) {
      const newcontent = operations.reduce((acc, curr) => {
        return acc.compose(curr.delta);
      }, contentRef.current);
      contentRef.current = newcontent;
      console.log("server updated...");
      setOperations([]);
    }
  }, [operations]);
  // API mock
  const getInitialState = () => {
    return contentRef.current;
  };
  // Handlers
  const onCreateUser = (username) => {
    if (users.includes(username)) {
      const message = `There's already an user with name "${username}". Try a different name`;
      alert(message);
      return;
    }
    setUsers((pre) => [username, ...pre]);
  };
  return (
    <>
      <div className="container">
        <h1 className="center">Operational Transform OT (Demo)</h1>
        <AddUser onCreateUser={onCreateUser} />
        <div className="users">
          {users.map((user) => (
            <User
              key={user}
              name={user}
              operations={operations}
              setOperations={setOperations}
              getInitialState={getInitialState}
            />
          ))}
        </div>
        <Footer />
      </div>
    </>
  );
}
export default App;// User.jsx
import Avatar from "boring-avatars";
import { useCallback, useEffect, useState } from "react";
import ReactQuill from "react-quill";
import Delta from "quill-delta";
import { debounce } from "../utils/debounce";
export function User(props) {
  const { name, operations, setOperations, getInitialState } = props;
  // State
  const [content, setContent] = useState(new Delta(getInitialState()));
  const [newOps, setNewOps] = useState(new Delta());
  // Effects
  // The effect that listens for any operations and does OT to keep the current doc in sync
  useEffect(() => {
    if (!operations.length) return;
    const indices = [];
    operations.forEach((operation, index) => {
      // To make it as a broadcast event.
      if (operation.name === name) {
        // As return in forEach just breaks the current execution
        return;
      }
      indices.push(index);
      // As compose is done on the `value` prop of `<ReactQuill />` -  see the codebelow
      const transformedDelta = new Delta().transform(operation.delta, true);
      const composedDelta = content.compose(transformedDelta);
      setNewOps(new Delta());
      setContent(composedDelta);
    });
    if (!indices.length) return;
    const newOperations = operations.filter(
      (_, index) => !indices.includes(index)
    );
    setOperations(newOperations);
  }, [operations]);
  // Handlers
  const handleDebounceChange = (delta, currentDelta, oldDelta) => {
    const diff = oldDelta.diff(currentDelta);
    setContent((pre) => pre.compose(diff));
    setNewOps(new Delta());
    setOperations((pre) => [...pre, { delta: diff, name }]);
    console.log("Operations pushed---");
  };
  const debouncedHandleChange = useCallback(
    debounce(handleDebounceChange, 2000),
    []
  );
  const onChange = (_, delta, source, editor) => {
    // If the change is not caused due to user input ignore...
    if (source === "api") return;
    const deltaContents = editor.getContents();
    const diff = content.diff(deltaContents);
    setNewOps(diff);
    debouncedHandleChange(delta, deltaContents, content);
  };
  return (
    <div className="user">
      <div className="user-info">
        <Avatar
          size={40}
          name={name}
          variant="beam"
          colors={["#92A1C6", "#146A7C", "#F0AB3D", "#C271B4", "#C20D90"]}
        />
        <h3>{`${name}'s machine`}</h3>
      </div>
      <div className="editor">
        <ReactQuill value={content.compose(newOps)} onChange={onChange} />
      </div>
    </div>
  );
}So with this starter, we can build further to get a good collaborative editing experience.
The above blog is to show how OT works. And it is not a real-world example. It needs a dedicated system design and approach based on your requirements. Providing follow-up links to dive deeper into this.
I used this to implement a collaborative live experience in a project of mine. I'm still learning more about this. If you have better solutions or approaches, please let me know š.
Thanks for reading!
Love to connect with you all!
Blog ⢠Email ⢠LinkedIn ⢠GitHub ⢠X
References
Links in this blog
- Live Preview
- Github Repository
- Quill Delta
- 
    quill-deltapackage
- 
    react-quillpackage
More about Operational Transformation

