DEV Community

Cover image for Add Real-Time Collaboration to Your React App in 10 Minutes
Afonso Gonçalves
Afonso Gonçalves

Posted on • Edited on

Add Real-Time Collaboration to Your React App in 10 Minutes

The other day, someone I know messaged:

"I found myself over the holiday trying to explain to my son how investment returns worked over Zoom and it would’ve been so easy if we had both been able to work inside of something like a Google Doc"

Challenge accepted!

During the past few days I had fun building an investment return simulator, where we can forecast investment returns based on multiple parameters.
The internet is full of such websites, but in this one, I wanted to explore how we can embed collaborative features, and how they can bring people together.

In this guide, we'll see how we can add real-time collaboration to a regular React app in just 10 minutes without writing any backend code or dealing with web sockets. As a bonus, we'll also explore how we can enhance user experience in collaborative websites!

The full source code for this project is available on GitHub

Let's dive in!

The Starting Point: Building the Foundation

Before diving into the collaborative features, we needed a solid foundation. I began by creating a single-player version where users could enter their investment parameters. These inputs would then feed into a calculation engine that generates and displays investment return forecasts.

I used an AI code generator to get up and running quickly. I was impressed by the clean design it gave me, and with how fast I got to an acceptable starting point. Despite the huge head start, I still needed to fine tune quite some calculation logic, and adjust the UI to my liking.


Making the application collaborative

With the single-player version complete, I turned my attention to making it collaborative. This project presented a perfect opportunity to test React Together, an open-source library I've been developing at Multisynq for the past few months.

React Together provides hooks and components that enable collaborative features without the complexity of setting up backends or managing socket connections.

Now, let's walk through the three steps to add real-time collaboration to our app! Start your timers 😉

Step 1: Setting Up the React Together Context

The first step is wrapping our application in a React Together context provider. This component handles all the state synchronization and session management behind the scenes.

// src/index.tsx
import { StrictMode } from 'react'
import { ReactTogether } from 'react-together'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ReactTogether sessionParams={{
      appId: import.meta.env['VITE_APP_ID'],
      apiKey: import.meta.env['VITE_API_KEY'],
    }}>
      <App />
    </ReactTogether>
  </StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

React Together uses Multisynq's infrastructure for application synchronization, which requires an API key. You can get your free API key from multisynq.io/account. And don't worry, these keys are meant to be public, since you can control which domains can use them.

We could configure React Together to automatically connect all users to the same session once they enter the website. In fact, that would make this a 2-step guide, but I went for a Google Docs-style approach where collaboration is opt-in. Users remain disconnected until they explicitly create or join a session through a button click. We will cover session management on the third step of this guide!

Step 2: Synchronize state across users

With React Together set up, the next step is to synchronize state between users. This process is incredibly simple: we just need to replace React's useState hooks with React Together's useStateTogether hooks.

The useStateTogether hook works similarly to useState, but requires an additional rtKey parameter. This key uniquely identifies the state across the application, ensuring proper synchronization even in responsive layouts where DOM hierarchies might differ between viewports.

Here's how the transformation looks:

// Before
import { useState } from 'react'

export default function Calculator() {
  const [startingAmount, setStartingAmount] = useState(20000);
  const [years, setYears] = useState(25);
  const [returnRate, setReturnRate] = useState(10);
  const [compoundFrequency, setCompoundFrequency] = useState("annually");
  const [additionalContribution, setAdditionalContribution] = useState(500);
  const [contributionTiming, setContributionTiming] = useState("beginning");
  const [contributionFrequency, setContributionFrequency] = useState("month");

  // ...
}
Enter fullscreen mode Exit fullscreen mode
// After
import { useStateTogether } from 'react-together'

export default function Calculator() {
  const [startingAmount, setStartingAmount] = useStateTogether("startingAmount", 20000);
  const [years, setYears] = useStateTogether("years", 25);
  const [returnRate, setReturnRate] = useStateTogether("returnRate", 10);
  const [compoundFrequency, setCompoundFrequency] = useStateTogether("compoundFrequency", "annually");
  const [additionalContribution, setAdditionalContribution] = useStateTogether("additionalContribution", 500);
  const [contributionTiming, setContributionTiming] = useStateTogether("contributionTiming", "beginning");
  const [contributionFrequency, setContributionFrequency] = useStateTogether("contributionFrequency", "month");

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The beauty of this approach is that the application continues to work exactly as before - the only difference is that now the state updates are synchronized across all connected users.

Step 3: Add Session Management

The final step is adding a way for users to create, join, and leave collaborative sessions. I chose to implement this through a header section above the calculator, making session controls easily visible to everyone.

Visible session management

React Together makes this straightforward by providing four essential hooks:

  • useIsTogether: Tells us if we're currently in a session
  • useCreateRandomSession: Creates a new private session
  • useLeaveSession: Disconnects from the current session
  • useJoinUrl: Provides a shareable URL for others to join

Here's a simplified version of the header component (I just removed the class names):

import { Button, Typography } from "antd";
import { Calculator as CalculatorIcon } from "lucide-react";
import { useCreateRandomSession, useIsTogether, useJoinUrl, useLeaveSession } from "react-together";

export function Header() {
  const isTogether = useIsTogether();
  const leaveSession = useLeaveSession();
  const createRandomSession = useCreateRandomSession();
  const joinUrl = useJoinUrl();

  return (
    {isTogether ? (
      <div>
        <p>Invite your friends to this session with the link below:</p>
        <a href={joinUrl}>{joinUrl}</a>
        <Button onClick={() => leaveSession()}>
          Leave Session
        </Button>
      </div>
    ) : (
      <div>
        <p>Want to collaborate with your friends?</p>
        <Button onClick={() => createRandomSession()}>
          Start a Session!!
        </Button>
      </div>
    )}
  );
}
Enter fullscreen mode Exit fullscreen mode

With this implementation, users can now start collaborative sessions with just a click. When someone joins using the shared URL, they'll immediately see the same state as everyone else, with all changes synchronized in real-time across all participants.

And that's it, it's easy and it just works! Plus you can do it in less than 10 minutes!!


Enhancing the Collaboration Experience

While the basic synchronization worked well, something felt off: elements were changing "by themselves" on the page, with no indication of who was making the changes. This is a common challenge in collaborative applications, and tools like Google Docs solve it by showing where other users are viewing and editing.

True collaboration isn't just about synchronizing state—it's about creating a sense of presence. Users need to "see" each other to work together effectively.

I initially considered to implement shared cursors, letting users see each other's mouse pointers. However, this approach presents challenges in responsive web applications:

  • Mouse coordinates don't map cleanly between different viewport sizes
  • Cursor positions often lack context—it's unclear why a user's cursor is in a particular location
  • The meaning of cursor position can be ambiguous across different screen layouts

Instead, I focused on what we really want to achieve with user presence:

  1. Help users feel that others are actively present
  2. Show which elements each user is currently viewing or editing

The solution? Highlight the elements that users are interacting with. This approach is simpler, more intuitive, and works reliably across all viewport sizes. Let's see how to implement this in two key areas: chart tabs and input fields.

Adding User Presence to Chart Tabs

Let's start with a simple implementation of user presence: showing which users are viewing each chart tab.

User presence in tab view

For this, we need a special kind of shared state where each user can have their own value that's visible to everyone else.

React Together provides exactly what we need with the useStateTogetherWithPerUserValues hook (yes, that's quite a mouthful!). This hook works similarly to useStateTogether, but instead of sharing a single value, it allows each user to have their own value that's visible to all participants. The hook returns three elements:

  1. The current user's local state value
  2. A function to update the local state
  3. An object containing each user's state value

Here's how we implement this to show user avatars next to tabs:

export default function Charts({ results }: ChartsProps) {
  const myId = useMyId();

  // Track which tab each user is viewing
  const [activeKey, setActiveKey, activeKeyPerUser] =
    useStateTogetherWithPerUserValues("active-keys", "1");

  const tabs = [
    {
      key: "1",
      label: "Growth Forecast",
      children: <BarChart results={results} />,
    },
    {
      key: "2",
      label: "Investment Breakdown",
      children: <PieChart results={results} />,
    },
  ];

  return (
    <Tabs
      activeKey={activeKey}
      onChange={(key) => setActiveKey(key)}
      items={tabs.map(({ key, label, ...rest }) => ({
        key,
        label: (
          <PresenceLabel
            label={label}
            // Get the other user IDs that are viewing the current tab
            userIds={Object.keys(activeKeyPerUser).filter(
              (id) => id !== myId && activeKeyPerUser[id] === key
            )}
          />
        ),
        ...rest,
      }))}
    />
  );
}

// This component renders the tab label with user avatars
function PresenceLabel({ label, userIds }: PresenceLabelProps) {
  return (
    <div className="flex flex-row gap-2 items-center">
      <span>{label}</span>
      <Avatar.Group>
        {userIds.map((userId) => (
          <Avatar key={userId} src={getUserAvatarUrl(userId)} size={20} />
        ))}
      </Avatar.Group>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, we replaced a useState with a useStateTogetherWithPerUserValues, and once again, the application kept working as it was before, but now everyone could see everyone else's state! Then we just needed to render the new information we just got.

This implementation shows user avatars next to each tab, making it clear which users are viewing which charts. We filter out the current user's avatar to avoid redundancy, as users don't need to see their own presence indicator.

Adding User Presence on Input Fields

Adding presence indicators to input fields follows a similar pattern to the previous example, but with an additional requirement: we need to track when users start and stop editing. Fortunately, Ant Design's components provide the necessary callbacks for this purpose.

For each input field, I wanted to:

  1. Show a colored border when someone else is editing
  2. Display the editor's avatar in the top right corner
  3. Maintain consistent colors per user throughout the application

Here's how we implement this using the useStateTogetherWithPerUserValues hook:

import { Avatar, Badge, Typography } from "antd";
import { useMyId, useStateTogetherWithPerUserValues } from "react-together";
import { getUserColor, getUserAvatarUrl } from "../utils/random";

const { Text } = Typography;

export function PresenceEditableText({ rtKey, value, onChange }) {
  const myId = useMyId();

  const [isEditing, setIsEditing, isEditingPerUser] =
    useStateTogetherWithPerUserValues(rtKey, false);

  const othersEditing = Object.entries(isEditingPerUser)
    .filter(([userId, isEditing]) => userId !== myId && isEditing)
    .map(([userId]) => userId);

  // Highlight with the first editor's color if we're not editing
  const borderColor = !isEditing && othersEditing.length > 0
    ? getUserColor(othersEditing[0]) 
    : "transparent";

  return (
    <Badge
      size="small"
      count={
        !isEditing ? (
          <Avatar.Group max={{ count: 1 }} size={16}>
            {othersEditing.map((userId) => (
              <Avatar key={userId} size={16} src={getUserAvatarUrl(userId)} />
            ))}
          </Avatar.Group>
        ) : null
      }
    >
      <span className={`border-solid border-${borderColor} border-2 rounded-md p-1`}>
        <Text
          editable={{
            triggerType: ["text"],
            enterIcon: null,
            onStart: () => setIsEditing(true),
            onChange: (v) => {
              onChange(v);
              setIsEditing(false);
            },
            onEnd: () => setIsEditing(false),
            onCancel: () => setIsEditing(false)
          }}
        >
          {value}
        </Text>
      </span>
    </Badge>
  );
}
Enter fullscreen mode Exit fullscreen mode

Although the code is slightly longer, the principle is simple: We just need to track which users are editing each input field, and then render whichever visualization we want.

This same approach works for any other input type, such as drop downs and slider bars!!

--

And that's it! With this fully collaborative investment return simulator it'll be easier for my friend to explain to his son how investment returns worked over Zoom. Mission accomplished! ✨

Looking at how easy it is to create this kind of collaborative website makes me wonder how can the internet bring us closer together when we're online... More on that later!

Hope you learned something new, and feel free to reach out if you have any feedback or questions!!

Happy coding! 🚀

Top comments (1)

Collapse
 
kenlane22 profile image
Ken

This is so cool!
More web pages should be "together".