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>,
)
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");
// ...
}
// 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");
// ...
}
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.
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>
)}
);
}
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:
- Help users feel that others are actively present
- 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.
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:
- The current user's local state value
- A function to update the local state
- 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>
);
}
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:
- Show a colored border when someone else is editing
- Display the editor's avatar in the top right corner
- 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>
);
}
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! 🚀
- 👉 Live Demo
- 🧑💻 Source Code
- 📚 React Together Docs
Top comments (1)
This is so cool!
More web pages should be "together".