Creating collabrative javascript spreadsheet made easy
Tags: webdev, excel, collabration, react
Published: 2022-04-26 09:43:58

In the [last post](https://dev.to/zyc9012/create-excel-like-javascript-spreadsheet-in-less-than-10-lines-of-code-4a5o), we have introduced how to create a javascript spreadsheet using [FortuneSheet](https://github.com/ruilisi/fortune-sheet). This time, we will show you how to enable it's collabration feature.

![collabration](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wft498vkhw2uvdd2yoqj.gif)

## Backend

Before we get into the topic, the first thing to be considered is backend storage, as the sheet data have to be saved so that others can see the lastest sheet state when they enter the page.

Websocket is our main transport of exchanging live data among clients. Here we choose Express as backend server, and MongoDB as database. The main role of express server is to manage websocket connection, serve initial data and process incremental `op` messages. 

In this demo we simply use all documents of a collection for the sheets of our demo workbook.

```js
async function getData() {
  const db = client.db(dbName);
  return await db.collection(collectionName).find().toArray();
}
```

We will skip the code of creating a simple express server, and focus on the core code. Don't worry, you can find the complete code at the end of this post.

Then, add some code for processing websocket messages, and manage connections for broadcasting.

```js
const connections = {};

const broadcastToOthers = (selfId, data) => {
  Object.values(connections).forEach((ws) => {
    if (ws.id !== selfId) {
      ws.send(data);
    }
  });
};

const wss = new SocketServer({ server, path: "/ws" });

wss.on("connection", (ws) => {
  ws.id = uuid.v4();
  connections[ws.id] = ws;

  ws.on("message", async (data) => {
    const msg = JSON.parse(data.toString());
    if (msg.req === "getData") {
      ws.send(
        JSON.stringify({
          req: msg.req,
          data: await getData(),
        })
      );
    } else if (msg.req === "op") {
      await applyOp(client.db(dbName).collection(collectionName), msg.data);
      broadcastToOthers(ws.id, data.toString());
    }
  });

  ws.on("close", () => {
    delete connections[ws.id];
  });
});
```

Here, the `applyOp` function is the core of collabration. It reads the `Op`s sent by our frontend library, and perform data mutations to the database.

## Processing Ops

The `Op`s are generated by the patch of `Immer.js`, for example, here is an op when user sets the cell font to be bold on cell A2.

```json
[
    {
        "op": "replace",
        "index": "0",
        "path": ["data", 1, 0, "bl"],
        "value": 1
    }
]
```

We have to convert this op into MongoDB update query. If we convert it directly, the result will look like:

```js
db.updateOne(
  { index: "0" },
  { $set: { "data.1.0.bl": 1 } }
);
```

However, considering storage size, we stores cell data **sparsely** in the database, that is, instead of storing the entire 2-dimension cell array, we store a 1-dimension array of cells that contain values. Thus, a cell in the database is in the form of
```ts
{
   r: number, // row index
   c: number, // column index
   v: any, // cell value
}
```

and the above update query becomes:

```js
db.updateOne(
  { index: "0" },
  { $set: { "celldata.$[e].v.bl": 1 } },
  { arrayFilters: [{ "e.r": 1, "e.c": 0 }] }
);
```

Updates to others fields of the sheet are similar.

And that's all of our backend server. For the complete code, see https://github.com/ruilisi/fortune-sheet/tree/master/backend-demo

## Frontend

Now let's focus on the frontend part, which is quite simple.

Step 1, create a websocket connection.

```jsx
const wsRef = useRef<WebSocket>();

useEffect(() => {
  const socket = new WebSocket("ws://localhost:8081/ws");
  wsRef.current = socket;

  socket.onopen = () => {
    socket.send(JSON.stringify({ req: "getData" }));
  };
}, []);
```

Step 2, send ops from local change, receive ops from others and apply them into the workbook.

```jsx
// A ref of Workbook
const workbookRef = useRef<WorkbookInstance>(null);

// In useEffect
socket.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.req === "getData") {
    setData(msg.data);
  } else if (msg.req === "op") {
    workbookRef.current?.applyOp(msg.data);
  }
};

// Workbook declaration
<Workbook
  ref={workbookRef}
  onOp={(op) => socket.send(JSON.stringify({ req: "op", data: op }))} />
```

For the complete code, refer to https://github.com/ruilisi/fortune-sheet/blob/master/stories/Collabration.stories.tsx

## Thanks for reading
The repo is hosted on [Github](https://github.com/ruilisi/fortune-sheet), completely open source. Give us a star if you find it useful 😄. Feedbacks are much appreciated!