DEV Community

Cover image for Build a Realtime Collaborative Whiteboard with Supabase & React
Keyur Paralkar
Keyur Paralkar

Posted on • Edited on

Build a Realtime Collaborative Whiteboard with Supabase & React

Have you guys every tried to use figma or miro? If you have used it then I am pretty sure you might have seen this feature where all of a sudden you start to see multiple cursors on your figma file or miro board. If you haven’t seen this ever yet, here is the quick video of what I am talking about.

Figma's Realtime Experience
Figma's Realtime Experience

Miro's Realtime Experience
Miro's Realtime Experience

Isn’t it sleek. I mean just look at it, it looks so cool. Can you imagine how they must have build this feature? What did it take to build this to provide such a good dynamic user experience? Bye bye screen share, just follow that persons cursor and here to the audio in the meeting and you are good to go.

I just love this feature. So I set out on a quest on understanding the feature and what would it take to build such a feature. I must say it was really a pain to understand it TBH but things got way easier later. So let us dive deeper into the understanding and implementing this project.

The What?

We are trying to build sticky notes board application, in which we are able to see multiple cursors and sticky notes as a status of what other users are doing on the same website. In this project every user has the information about all the other users, like where their cursor is moving, what they are typing, which note they are dragging etc.

To give you guys an idea here is the video that shows the application in action:

Realtime App in action

Prerequisites

To get the most out of this blog post, I highly recommend that you guys be familiar with the below topics:

The Why?

So I know and this question might have arrived to you as to why are we doing this. We are doing this because:

  • To gain insights on how innovative UX works.
  • Understand some critical UX decisions

Some backstory

GTA:SA Meme

So before we dive into the implementation, I would like to share a few things about this project(my personal exp.). During this project I studying a ton about webRTC. I started studying webRTC from scratch, like what is webRTC? how is the connection formed? how the data transfer takes place.

I took this decision of choosing webRTC because I thought each client needs information about every other client connected over the network/room. So isn’t this same as the video conferencing apps like Microsoft Teams, Zoom, Google Meet etc. In these apps, every client knows about every other client and they share the stream of data directly with the help of webRTC(that’s what I thought). But it turns out it’s not that simple, this would create a mesh kind of a network and would be resource intensive over the network. So there are better protocols/patterns to handle such cases like SFU(Selective Forwarding Unit).

Ok I went too back, but you guys get the point right. So I though it’s just a simple project. rather than having multiple streams of video shared between each other I just need to replace it with the cursor position of every other client. How hard can it be right? actually its very hard.

I tried to first implement the webRTC full mesh pattern just to understand how every client would share their stream with others. But managing the connection i.e. offer negotiation in webrtc got way tricky when more than 2 clients came in so I dropped the idea.

So instead, I pivoted and moved on to what supabase’s realtime DB does. I actually used it in this project. Yeah, so that was the backstory but let’s continue.

The How?

This is the part where we understand the implementation of the entire project. I have used the following tech stack:

  • For Frontend - React.js and Vite
  • For Backend - Supabase

Before getting into the frontend, let us understand what the backend is and how it helps us to achieve our results.

💡 NOTE: This blog post is not going to be a step-by-step guide but rather a walk through to the codebase along with the detailed explanation of the concepts/architecture

Enter Supabase

Supabase is a backend as a service visual platform that allows you to create postgres DB with minimum code. Their documentation is so good that it feels like home and you can get your project online in no matter of time.

So this cool low-code no-code platform provides a realtime feature that lets you do a bunch of stuffs:

  • Listen to updates to the DB inserts, updates and any other changes
  • Update your frontend state globally with Presence API
  • You can also broadcast your changes to all the connected clients.

Out of these 3 we are going to make use of the Presence API.

Requirements

So let us look at the things we need to build our Sticky notes board app:

  • We need a way to have a live cursor feel i.e. my screen should also show where other folks are moving their cursors
  • We also need a way to let my screen know that what others are doing like, adding a note and moving the note
  • If any of the folks closes their browser window, then my screen shouldn’t contain changes from the guy who left.

All of these requirements can be achieved using the Supabase’s Presence API. It is the perfect recipe to achieve our thing.

Before we start going in depth on this API we first need to understand what channels are.

  • Channels:
    • This is the basic building block of the realtime feature.
    • Channel is where all the clients connect to. Imagine that Channel is nothing but a room where all the people can join in.
    • Whenever a new client comes in, we need to make sure that it connects to this room/channel.
    • You can find details regarding the same here

Now let us understand the Presence API. It is an API that helps you share your current client state with all the other clients. It does that with the help of the track function. So a typical track function call will look like below:

const channel = client.channel('room1'); // creates a channel 'room1' 

channel.track({
    clientId: '123',
    color: 'red'
})
Enter fullscreen mode Exit fullscreen mode

Each channel has a common pool of states for all the clients. Now if a user, suddenly closes his tab i.e. if the client is removed from the channel then this state is also updated. We will look at the details of the other events like join leave and sync of the presence API in the later section of this blog post.

Now that we know the presence API, so let us start off our journey to understanding the project architecture.

Project Setup

A little detour here, I would like to talk about the frontend tooling/project environment here. From the frontend standpoint I am using Vite to create the project scaffoldings. I have used a typescript template of Vite since the project is build with typescript.

yarn create vite --template react-ts
Enter fullscreen mode Exit fullscreen mode

Make use of the above command to create a Vite project with react based typescript template.

From the backend standpoint, I am making use of supabase’s local setup. I won’t be covering that in this blog post because supabase guide has done a pretty nice job at explaining it here.

Other libraries used are as follows:

  • Supabase-js: This is the client side wrapper of supabase. You can read more about it here
  • Nonoid: A library that helps you to generate unique string ids. Read the full documentation here

Project Architecture

So we are all set from the project setup standpoint. So let us now understand by how the flow of the application would be:

  • Whenever a new tab is opened with our project on our browser that means we need to create a new client instance of supabase. So we start by creating a client.
  • Next, we create a channel so that the client gets added to that channel
  • We listen for the sync and leave events of the Presence API
  • On update of user activity we need to pass this data to all the other clients
  • If the browser window is closed, we need to make sure that all the clients won’t contain data related to the removed client.

Project Architecture
Project Architecture

After gaining this knowledge of the application flow we are in a good state to understand the implementation details. As I mentioned earlier since this is not going to be a step-by-step code walkthrough but an in depth core understanding blog post.

Now let us familiarise ourselves with the repository structure:

There are 3 files that we need to carefully look at:

  • App.tsx - The driver program where entire logic resides
  • component/Cursor.tsx - An SVG cursor component to show the pointer of the other clients
  • component/StickyNote.tsx - A component to show the stickynote that is editable.

We will go through each and every file to understand the application flow:

NOTE: I would highly recommend to open each file while you are reading the details of each file structure. In that way you won’t get lost.

App.tsx

Refer to this component while reading the below section.

  • This file will get loaded as soon as the browser tab hits our project URL. So according to the flow we should instantiate a supabase client which we do it like below:

    const clientA = createClient(SUPABASE_URL, SUPABASE_KEY);
    
  • Once the client is instantiated, we expect that it should create a channel like below:

    const channel = clientA.channel("room-1");
    
    • For a new client it will check that the channel with room-1 exists or not. If it exists, it joins the channel or else create one.
  • On mount of our main app here: , we expect the client to subscribe to the presence API’s sync and leave events. To do this we first create the event handlers that listen to these events like below:

useEffect(() => {
            channel.on("presence", { event: "sync" }, () => {
                const newState = channel.presenceState<Clients>();

                // code to manage the state once other client updates
        }, []); 
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
            channel.on<{ clientId: string }>(
                "presence",
                { event: "leave" },
                ({ leftPresences }) => {
                    // code to manage the state when any user leaves
                }
            );
        }, [removeClient]);
Enter fullscreen mode Exit fullscreen mode

Upon adding these event handlers we then subscribe them along like below:

useEffect(() => {
            if (isFirstRender.current) {
                subsChannel.current = channel.subscribe();
                isFirstRender.current = false;
            }
        }, []);
Enter fullscreen mode Exit fullscreen mode

This effect will execute on mount of the component. We also need to make sure that we don’t accidentally call the subscribe method more than once or else supabase will throw an error. This issue won’t happen in production but in strict mode i.e. in development mode this effect would run twice. That’s the react’s way to make sure that things are predictable.

So to avoid this, I added a ref isFirstRender. It gets set to false when the effect runs the second time in strict mode.

  • There is one thing to take note of and that is the structure of the data that we are syncing between all the clients. It is as follows:
    export enum EventTypes {
        MOVE_MOUSE = "move-mouse",
        MOVE_NOTE = "move-note",
        ADD_NOTE = "add-note",
        ADD_NOTE_TEXT = "add-note-text",
    }

    export type Note = {
        x: number;
        y: number;
        content: string;
    };

    type Payload = {
        eventType: EventTypes;
        x: number;
        y: number;
        color: string;
        notes: Array<Note>;
    };

    export type Clients = Record<string, Payload>;

Enter fullscreen mode Exit fullscreen mode

These are the typescript types but they translate to something like below:

    {
      "W3lf6J4shQUfE-DTkILBb": {
        "color": "rgb(15%, 30%, 40%)",
        "eventType": "move-mouse",
        "x": 829,
        "y": 235,
        "notes": [
          {
            "content": "This is note from client W3lf",
            "x": 202,
            "y": 350
          }
        ]
      },
      "JRlR_3qHr7B2bad2HVvWE": {
        "color": "rgb(94%, 30%, 40%)",
        "eventType": "move-mouse",
        "x": 16,
        "y": 65,
        "notes": [
          {
            "content": "This note from client JRIR",
            "x": 198,
            "y": 729
          }
        ]
      }
    }
Enter fullscreen mode Exit fullscreen mode

This would be a typical state that each client will hold. We also tell each client to hold the states of all the other clients as well which happens in the newClients state variable.

  • Now let us understand how each client get synced with all the other clients. Each client has 3 points where they update their own state:

    • When the mouse move happens,
    • When the note is added &
    • When the sticky note is moved.
    • When the sticky note is edited

    Whenever any of these scenarios happen we call their respective event handlers. So as you expect,

    • For the mouse move we would use onMouseMove ,
    • When note is added we use onClick of the button present on the screen,
    • For editing of sticky note, the onChange event of sticky note component is used.
    • And finally when the note is moved we use onMouseMove of the StickyNote component.

    And for all these scenarios they update their states with the help of channel.track function. It is a presence API function that send the update to all the other clients connected to the channel. You can read more about this function here.

Now to make our scenarios work, we make use of the following event handlers:

const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
            throttledChannelTrack({
                [CURRENT_CLIENT_ID]: {
                    ...newClients[CURRENT_CLIENT_ID],
                    eventType: EventTypes.MOVE_MOUSE,
                    color: randomColor,
                    x: event.clientX,
                    y: event.clientY,
                },
            });
        };

        const handleNoteAddition = () => {
            const currentClient = newClients[CURRENT_CLIENT_ID];

            // We want to add notes immediately, hence not using throttled version of track():
            subsChannel.current?.track?.({
                [CURRENT_CLIENT_ID]: {
                    ...currentClient,
                    eventType: EventTypes.ADD_NOTE,
                    notes: currentClient.notes
                        ? [...currentClient.notes, DEFAULT_NOTE]
                        : [DEFAULT_NOTE],
                },
            });
        };

        const handleNoteMouseMove = (currentNote: Note, noteIndex: number) => {
            const currentClient = newClients[CURRENT_CLIENT_ID];

            const notes = currentClient.notes;
            notes[noteIndex] = currentNote;

            throttledChannelTrack({
                [CURRENT_CLIENT_ID]: {
                    ...currentClient,
                    eventType: EventTypes.MOVE_NOTE,
                    notes,
                },
            });
        };

Enter fullscreen mode Exit fullscreen mode

Notice the throttledChannelTrack function. It is a throttle function, used only for frequently happening events such as mouse move and sticky note moves. For scenarios like note addition we directly make use of the channel.track function.

CURRENT_CLIENT_ID is the random unique string Id generated by nanoid that gets generated whenever the client is opened on the brower’s tab.

It’s nice to observe that on each track call we update the current client’s state and also keep its existing state as well.

  • Once the track function is called, then immediately the presence API’s sync event handler gets executed. It is the general working of the API that whenever a track call happens sync event handler gets executed. So now if the current client does any of the above scenarios, it will call the track function with the above payload and execute the sync event handler.

In our case, we need to make sure that we do the following things when this event handler executes:

- Capture the `presenceState` from the channel. This is a state that the channel maintains that consists of all the latest updates(presence events) made from all the other clients.
- Iterate through all the presence events and update the `newClients` state with all these presence events.
- What we are doing here is, all the presence event is nothing but each client’s own state from all the other clients. We are just capturing all the other clients state into the current client and updating it in a state variable called `newClients`.
- In this way, we keep track of other clients:
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
            channel.on("presence", { event: "sync" }, () => {
                const newState = channel.presenceState<Clients>();

                const presenceValues: Clients = {};

                Object.keys(newState).forEach((stateId) => {
                    const presenceValue = newState[stateId][0];
                    const clientId = Object.keys(presenceValue)[0];

                    presenceValues[clientId] = presenceValue[clientId];
                });

                setNewClients((preValue) => {
                    const updatedClients = Object.keys(presenceValues).reduce<Clients>(
                        (acc, curr) => {
                            acc[curr] = {
                                ...preValue[curr],
                                ...presenceValues[curr],
                            };
                            return acc;
                        },
                        {}
                    );

                    return updatedClients;
                });
            });
        }, []);

Enter fullscreen mode Exit fullscreen mode
  • One last thing that I would like to discuss here, is the updating the UI whenever a client is removed. We don’t want our UI to get cluttered from the sticky notes from the clients who have left the channel. Have a look at this video to get clear understanding of what I am saying:

    Client_removed.gif

    • This is pretty easy to achieve. We just need to add an event handler for the leave event of the presence API and update the newClients state variable with the current sync state between the clients. Here is the code for it:
useEffect(() => {
                channel.on<{ clientId: string }>(
                    "presence",
                    { event: "leave" },
                    ({ leftPresences }) => {
                        const { clientId } = leftPresences[0];
                        removeClient(clientId);
                    }
                );
            }, [removeClient]);

Enter fullscreen mode Exit fullscreen mode

That’s it for the entire application logic. Let us now take a brief look on the UI components. UI components are pretty straight forward. They would taken a bunch of props and display it in a good manner.

Cursor.tsx

Refer to this component while reading this component

This component would take in the x and y coordinates and the name of the client and show them in a good nelly welly way. Have a look at the below cursor image:

Custom Cursor
Custom Cursor

  • A quick thing to note about this component is that, it will render all the cursors expect for the current client/browser window. This is a UI descision that I took. I took this decision because we don’t want to show another pointer apart from the current pointer that we see i.e. the actual one.

StickyNote.tsx

Refer to this component while reading this component

This component simply renders all the notes in that are stored in the newClients state variable. It takes in the x and y coordinate of the current note and the content inside it.

  • One important thing about this sticky note is that even for the current client we show it’s note. This makes sense right because we want to know and see that we added a sticky note and it should appear on the screen hence this decision.
  • This component add a bit of jazz to the entire flow. What it would do is, it will highlight the notes from the other clients onto your screen. Your current sticky notes won’t be highlighted. In this way, we make clear distinction as to which note belongs to whom. Have a look at this small video/gif:

Sticky Notes component
Sticky Notes Component

  • You can even see that the color of the cursor matches with the highlights of the sticky notes for the clients that are external to the current clients.

Summary

This project gave me good learnings. Below are some of them:

  • Use a callback function with the setter to update state variables effectively, avoiding infinite renders caused by placing state updates in useEffect hooks triggered by dependency changes.

  • Avoid initializing state variables with props to prevent potential issues where subsequent prop changes are not reflected. Instead, update the state when props change to ensure accurate data representation.

Overall in this blog we looked at:

  • Why are we doing this project
  • Understood the backstory
  • Project Architecture
  • Presence API
  • UI components: Cursor and Sticky notes
  • Hiding the cursors and highlighting sticky notes for better user experience.

The entire codebase for this project can be found here

Thank you for reading!

Follow me on twittergithub, and linkedIn.

Top comments (1)

Collapse
 
maxnguyen profile image
Max Nguyen

Hi, how did you make Supabase realtime to work with NanoID as primary key? My table has NanoID and it can only detect DELETE, not INSERT or UPDATE. Thanks