🔧 Mastering Real-Time Collaboration: Building Figma and Miro-Inspired Features with Supabase
Nachrichtenbereich: 🔧 Programmierung
🔗 Quelle: dev.to
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.
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
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'
})
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
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
andleave
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.
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);
- You can read more about
createClient
function here: https://supabase.com/docs/reference/javascript/initializing
- You can read more about
-
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.
- For a new client it will check that the channel with
On mount of our main app here: , we expect the client to subscribe to the presence API’s
sync
andleave
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
}, []);
useEffect(() => {
channel.on<{ clientId: string }>(
"presence",
{ event: "leave" },
({ leftPresences }) => {
// code to manage the state when any user leaves
}
);
}, [removeClient]);
Upon adding these event handlers we then subscribe them along like below:
useEffect(() => {
if (isFirstRender.current) {
subsChannel.current = channel.subscribe();
isFirstRender.current = false;
}
}, []);
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>;
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
}
]
}
}
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 thebutton
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,
},
});
};
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 happenssync
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 thesync
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:
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;
});
});
}, []);
-
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:
- This is pretty easy to achieve. We just need to add an event handler for the
leave
event of the presence API and update thenewClients
state variable with the currentsync
state between the clients. Here is the code for it:
- This is pretty easy to achieve. We just need to add an event handler for the
useEffect(() => {
channel.on<{ clientId: string }>(
"presence",
{ event: "leave" },
({ leftPresences }) => {
const { clientId } = leftPresences[0];
removeClient(clientId);
}
);
}, [removeClient]);
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:
- 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:
- 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 twitter, github, and linkedIn.
...
🔧 🎨 Day 18: Mastering Layers in Figma 🎨
📈 25.34 Punkte
🔧 Programmierung
🔧 Building a RAG with Supabase Vector & OpenAI
📈 23.89 Punkte
🔧 Programmierung
🎥 Edward Miro - SE Village
📈 23.88 Punkte
🎥 IT Security Video
📰 Mural vs Miro: Das bieten die Anwendungen
📈 23.88 Punkte
🖥️ Betriebssysteme