Add collaborative editing experience to your react app in 10 mins.

Add collaborative editing experience to your react app in 10 mins.

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

Before getting started with the solution. Take a pause and come up with a set of problems caused when a document is concurrently edited by more than one user, And a solution that fixes all the problems...

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.

Default flow

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.

The goal is to ensure that the end result is consistent and reflects the intentions of both users.

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?

Google Docs with multiple tabs and multiple cursors

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.

  1. Add contents

  2. Apply styles (bold, italics)

  3. 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

  1. Insert {insert: "Hello!"} - Inserts Hello! at a particular position

  2. Retain {retain: 5} - Retains 5 characters. (Will make more sense later)

  3. 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 as delta (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-delta

Create a file app.js and paste the code below in that file.

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 app

The 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,

  1. <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.

    1. operations: {delta: Delta, name: string}[] - The temporary operations that are being transferred between clients.

      1. delta - The Delta object from quill-delta that has the current change.

      2. name - User's name who is responsible for the change. (So it doesn't need to be applied for that user)

    2. content: Delta - The actual content of the document that is centralized in the server.

    3. users: string[] - Number of users that the current room has.

  2. <User /> - Acts as individual browsers/users who can view and edit the document in the rich text editor.

    1. If user changes the document, it sends the changes to the operations state value. (View code for more info)

    2. 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

  1. 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)

  2. 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!

BlogEmailLinkedInGitHubX

References

Links in this blog

More about Operational Transformation

Did you find this article valuable?

Support Sivanesh Shanmugam by becoming a sponsor. Any amount is appreciated!