Get to know how to add multi-user and real-time collaboration to diagrams build in React Flow using Yjs.
Adding multi-user real-time collaboration to React Flow diagrams sounds like a great feature. It's complex, not so straightforward, but entirely possible. The real question is—how do we make it work? How to implement it into your software project seamlessly? Are there off-the-shelf solutions, or will we need to build from scratch? And what exactly do we need?
Multi-user real-time collaboration is a full-stack topic. It isn’t just about the front-end. For a smooth collaborative diagramming experience, we need to look at things from all angles—front-end (React, React Flow), back-end, and even the underlying architecture. In this post, we’ll explore the options, the challenges, and how to overcome them.
When building rich client applications, sooner or later we must face the topic of concurrency. Users will need to share resources, view each other’s content, and sometimes even edit it—sounds like standard app behavior, right? However, it adds complexity. Allowing multi-user editing of the same document at the same time can lead to conflicts. While simple solutions like read-only modes or check-in/check-out mechanisms can help, they aren’t exactly the smooth, collaborative experiences users expect today. With apps like Google Docs or Miro setting the bar for real-time collaboration, multi-user editing is demanded wherever you look—with changes reflected live, seamlessly, and without friction.
This is where multi-user real-time collaboration comes in: it’s about techniques that allow multiple users to interact with the same document in real time, avoiding any possible conflicts. No matter if we’re dealing with a drawing canvas or a text document, the fundamental techniques for introducing multi-user editing are the same, but they’re different from the typical client-server model. Sounds confusing? I know. But don’t worry—we’ll break it all down for you.
A quick note: While this article focuses on React Flow, as collaborative diagramming and data visualization are our main areas of expertise at Synergy Codes, the insights we discuss here are broadly applicable to all types of multi-user real-time collaboration apps. That said, diagramming introduces unique challenges not typically found in text-based applications, and text editors come with their own set of issues.
When it comes to adding multi-user real-time collaboration, you have the following options to consider:
Building a custom solution might be tempting for developers who love a good challenge, but I’d strongly advise against it. The complexities of multi-user real-time collaboration are often underestimated. Without a solid understanding of concurrent and distributed systems, you’re likely to run into issues that are already well-understood and easily solved by existing solutions.
So, we're left with two options for adding multi-user real-time collaboration into your software.
Libraries are the most flexible solution since they can be integrated into almost any project. Most of them are implementations of CRDT (Conflict-free Replicated Data Type), which is a data structure designed specifically for distributed data systems and multi-user real-time collaboration. If you're really curious about CRDTs, there's a wealth of research on the topic, and the CRDT.tech website is a great place to start.
What libraries are available? Some of the more well-known ones include Yjs, Loro, Automerge, Logux, TinyBase. Keep in mind, though, that not all libraries will include communication protocols out of the box—they may just focus on conflict resolution through CRDTs. Before using them, dig into the documentation and read this article until the end.
On the other hand, services might come with more limitations, but using them is more straightforward. Some of them may even be easily integrated into your existing tech stack. Just to name a few: Liveblocks, Superviz, Electric, Supabase Realtime. Even if you decide to use a cloud service, it’s still worth reading through this article—since you may run into development and architectural issues that we describe here. Plus, some services actually rely on the same libraries mentioned earlier, so understanding the underlying technologies can still be helpful.
At Synergy Codes, we strongly recommend Yjs for multi-user real-time collaboration. It’s a free, open-source, MIT-licensed JavaScript library implementing Yjs CRDT that can be used both on the client and server side.
One of the key advantages of Yjs is its flexibility—it doesn’t restrict you to specific use cases, giving you the freedom to adapt it to various projects. This flexibility ensures that Yjs can be used in everything from multi-user text editing to complex collaborative diagramming. Yjs is also lightweight, battle-tested, and ready for production use. Additionally, Yjs awareness allows real-time updates on who is currently interacting with your project. At Synergy Codes, we’ve successfully developed multi-user real-time collaboration apps with Yjs from scratch, as well as integrated it seamlessly into existing apps.
If you’re curious about our Yjs expertise or want to try it out yourself, check out our demo app.
While this article will now focus on using Yjs, the concepts and challenges we’ll discuss are present in other CRDT implementations, or even more broadly, in other multi-user real-time collaboration apps.
Before diving deep into how Yjs works and how to integrate it with your app, let’s start with a hands-on example. We’ll build a simple React app using Yjs for state management to enable multi-user real-time collaboration. Once we get the basics down, we’ll transition to React Flow, but let’s first lay the foundation with something straightforward using multi-user real-time collaboration principles.
I won’t walk you through the process of setting up a React project—you’re free to use whatever method you prefer (but I’d recommend steering clear of Create React App, since it’s deprecated). For this example, I’ve built a very simple to-do list app.
The key file here is use-todo.ts, as it contains the whole app's logic. By taking a look at the code, you’ll notice the state is made up of three variables:
Using a map for to-do items and defining order separately may seem unconventional for a typical to-do app, where items are often stored in arrays. However, this structure will make it much easier to adapt the code for Yjs, and it mirrors the kind of setup you might encounter in real use cases, such as collaborative diagramming apps.
The rest of the code isn’t crucial to this tutorial, as we’ll be focusing primarily on enabling multi-user real-time collaboration by modifying the app’s logic.
Before we integrate Yjs into our app, we need to install two packages:
Once the packages are installed, let's add a new entry to package.json for running a local Yjs WebSocket server with persisting data on a disk:
Now that we’ve added the Yjs WebSocket server, we can start it and let it run in the background with: npm run ws-server.
Now, let's revisit the use-todo.ts file. We need to start with two steps:
Unlike state management libraries like Redux, that keep everything in a store, Yjs keeps its data in a document, initialized using new Y.Doc().
Y.Doc itself can store only special data structures called shared data types. While they might remind you of Redux slices, they work differently. These data structures not only define how data is organized but also handle conflict resolution. There are six of them available and they cover all popular use cases:
Since our to-do app relies primarily on maps, we’ll use Y.Map. To create a new map in Y.Doc (or get an existing one), we use the doc.getMap() function. So, let's create the document with proper maps (we only need to synchronize todoItems and order, as currentEditedItem doesn't need to be synchronized):
However, creating Y.Doc itself only gives us state management. We still need connectivity between users. We’ll add it by instantiating the WebSocketProvider with the address of our Yjs WebSocket server:
The last line adds a simple logging mechanism, so we can track the connection status.
Notice that we define all of these outside of the React hook. That's because Yjs isn't a React solution and if not needed, we shouldn't keep it in React's lifecycle.
Since Yjs isn't a React library, we need to write our own bindings. This involves handling two cases—reacting to changes in Yjs and sending changes to Yjs. Let's focus on the first one.
Each shared type implements an observer pattern by providing an observe function (along with unobserve). This allows us to define a callback that triggers whenever the data changes. To make things simple, we can tap into the existing useState. State can be easily accessed by converting the shared type with the toJSON() function. In the case of Y.Map, it will return a plain JavaScript object.
As it has to be done before rendering anything, we should create a useEffect without a dependency array for registering observers:
You might have noticed that we invoked functions despite setting them to observe. That's because they only trigger when Yjs gets new data, and we'd rather have an initial state. You also need to remember about unobserving in the effect's destructor to prevent memory leaks.
At this point, our local state is in sync with what's happening in Yjs. However, when a user interacts with the to-do list, they're not just modifying Yjs—they're also updating the local state. Let's break down how we handle this with two examples.
Y.Map has a set(key, value) function. As expected, this function sets a value to a given key. Since our example relies solely on maps, that's the only thing that we’ll need to use.
First, let's check the addNew function, which adds a new to-do item to the state. Currently, it recreates objects in the state with an added new key-value pair:
In Yjs, there's no need to create a new map—we can simply set the new value. This way, the code gets simplified to:
Another scenario involves updating a specific value in an object. Since our to-do items are JavaScript objects, we can independently set the title and description. For example, updating the title currently looks like this:
In Yjs, we'll do it this way:
As you can see, we're recreating the object within the key-value pair. That's done so that Yjs is aware that there was a value change. It's aware of changes only on the level of shared types, but not deeper in them.
Following this same approach, you can update other functions as well, such as modifying the description or reordering items in the list.
In addition to shared types, Yjs providers use another mechanism for sharing changes: Yjs Awareness.
Yjs Awareness is used to share user state between all connected clients. By user state, we mean data that shouldn't be subject to conflict resolution, can only be edited by a single client at a time, and everyone should be aware of. A common example is cursor position, but Yjs Awareness can also be used in other situations.
Since Yjs Awareness is a part of the Yjs provider, we can find it in our WebsocketProvider, not in Y.Doc:
So, what can we do with it in our to-do app? For example, we have a local state that tracks what the user is editing. Why not share this information so all clients can see what each user is working on? This can be easily done in the following way:
As you can see, Yjs Awareness also implements the observer pattern, but the usage is a bit different than Y.Map. Instead of using observe(), we add an event listener with on(). In the observer, we iterate over each Yjs Awareness entry and copy them to our local state—excluding our own entry by checking the client ID.
Yjs Awareness itself is also a map, where client IDs are the keys, and the associated values can be anything. Now, it’s up to you to leverage this information in the UI. For example, you could highlight or change the color of the to-do item currently being edited by another user.
Adding multi-user real-time collaboration to our to-do app wasn't a hard task. We kept most of the original code intact and simply added some extra things to ensure integration with Yjs.
You can find the complete example here: https://github.com/synergycodes/yjs-example-react-todo-app.
If you prefer, you can view only the diff of changes here: https://github.com/synergycodes/yjs-example-react-todo-app/compare/initial-app...main.
Here’s a quick rundown of what we did:
Remember, the to-do app is one of the simplest types of apps, so adding multi-user real-time collaboration to it wasn’t particularly challenging. As app complexity increases, so will the complexity of working with Yjs. However, these basic steps are the foundation you’ll work with most often when implementing multi-user real-time collaboration.
While to-do list apps are nice and simple, many of us don’t spend our professional lives working on them. Instead, we often dive into more complex projects. Let's explore how you can integrate Yjs with React Flow to build collaborative diagramming apps.
The basics remain the same: You’ll need Yjs, a connection provider, and Y.Doc. What differs is the content and usage.
Here's a high-level overview of what needs to be done:
Additionally, you may want to synchronize cursor positions via Yjs Awareness, but for now, let's focus on getting the basics set up.
In React Flow, we operate on two important arrays in the state: nodes and edges, which together represent the entire graph. However, in Yjs, we won't keep it this way. Instead, a better structure for Yjs is a key-value map, as it allows for precise edits—a crucial factor for conflict resolution. Fortunately, our nodes and edges already have unique IDs, so there’s nothing stopping us from doing it this way.
Observing both structures is straightforward and works the same way as in our to-do list example. Things get interesting in updating Yjs, because React Flow requires us to implement onNodesChange and onEdgesChange functions. These functions send specific changes along with their type, which is great for targeted updates but also introduces some complexity.
For example, when writing onEdgesChange, we could write a code like this to handle different changes in Yjs:
The first case handles adding or replacing the edge, which is a straightforward use of the set() function. The second case deals with removing the edge.
Of course, this sample code isn’t a complete solution—there’s still more work to do. You’ll need to handle node changes, and it would be nice to have user cursors as well.
Thankfully, with what we’ve covered so far, implementing these features should be straightforward. However, if you’re looking for a full working demo, React Flow Pro comes with an example of using React Flow with Yjs.
The React Flow Pro example is a great starting point for building multi-user real-time collaboration apps with React Flow and Yjs. While it’s a solid foundation for collaborative diagramming, there are areas where things could be optimized or handled differently.
Here’s how our approach differs:
Consider these differences when moving the code into your own app. It's also worth noting that Yjs includes built-in undo/redo functionality, which is not a part of this implementation—but could be a valuable addition.
From the example we just explored, Yjs might seem like just another state management library. Aside from running an additional server, there were no major changes to the technical stack. That’s the beauty of Yjs: it’s that simple! But bear in mind that this was a very basic use case—in real-world applications, things get more complex.
When working with Yjs—or multi-user real-time collaboration apps, in general—we typically face three implementation scenarios:
Yjs operates differently from typical client-server or request-response solutions. To use Yjs effectively, we need to understand it on multiple levels. Let me guide you through the most common problems we've encountered and how to tackle them.
Fill the form below to get an e-book where you'll find the full version of this guide with additional information on: