Back to Blog
When Your UI Needs to Be Truly Collaborative: Yjs for Real-Time Shared State
7 min readSep 5, 20263 views

When Your UI Needs to Be Truly Collaborative: Yjs for Real-Time Shared State

Building a real-time collaborative application is notoriously difficult. Yjs offers a pragmatic solution by providing shared data types and a robust synchronization engine, enabling multiple users to edit the same document without complex conflict resolution logic.

FrontendWeb DevelopmentDistributed SystemsSoftware Design
Share

by Sunil Band

The Hard Problem of Real-Time Collaboration

We've all used Google Docs or Figma, where multiple cursors dance around the screen, and edits appear instantly. It feels magical, but achieving that seamless experience in your own applications is a beast. The moment you introduce more than one client editing the same data, you're not just dealing with state management; you're wrestling with distributed systems. How do you ensure everyone sees a consistent view? What happens when two users edit the same line of text concurrently? Most applications punt on this, falling back to "last write wins" or locking mechanisms, which are terrible for user experience.

The core challenge lies in conflict resolution. Traditional state management, even with immutability, assumes a single source of truth or a controlled update flow. In a collaborative environment, every client is a potential source of truth. You need a way to merge divergent states intelligently, often at a granular level, without losing data or forcing users to wait. This is where Conflict-free Replicated Data Types (CRDTs) come into play, and Yjs is a fantastic, production-ready implementation of this concept.

Yjs: Shared Data Types, Not Just State

Yjs isn't just a state management library; it's a framework for collaborative data structures. Instead of telling you how to sync your existing application state, it provides its own data types that are inherently collaborative. This is a crucial distinction. When you use a Y.Text or Y.Map, you're not just storing data; you're storing data that knows how to resolve conflicts and propagate changes across multiple clients automatically. It's like having a database that lives in every browser, constantly synchronizing with its peers.

This approach drastically simplifies development. You don't write custom diffing or merging logic. You operate on Yjs data types just like you would on native JavaScript objects, and Yjs handles the hard parts of ensuring eventual consistency and conflict resolution. It's optimized for efficiency, sending only the diffs, not the whole document, and it's framework agnostic. You can integrate it with React, Vue, Svelte, or vanilla JavaScript.

Getting Started with Yjs

Let's build a simple collaborative text editor. We'll need a Yjs document, a Y.Text type to hold our content, and a provider to handle the actual network synchronization. For this example, we'll use y-websocket, which connects clients to a WebSocket server. You'd typically run a small y-websocket server alongside your application, but for quick testing, there are public instances you can use.

First, install the necessary packages:

bash
npm install yjs y-websocket

Now, let's set up a basic React component. We'll use a textarea for editing and connect it to our Y.Text instance.

typescript
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// A public test WebSocket server. For production, you'd run your own.
const WEBSOCKET_URL = 'ws://localhost:1234'; // Or 'wss://demos.yjs.dev/demos/websocket/YOUR-ROOM-NAME'
const DOC_NAME = 'my-collaborative-document';

function CollaborativeEditor() {
  const ydocRef = useRef<Y.Doc | null>(null);
  const yTextRef = useRef<Y.Text | null>(null);
  const providerRef = useRef<WebsocketProvider | null>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Using useState to re-render when text changes, though Yjs also supports direct DOM manipulation.
  const [text, setText] = useState('');

  useEffect(() => {
    // Initialize Y.Doc and Y.Text only once
    if (!ydocRef.current) {
      const ydoc = new Y.Doc();
      const yText = ydoc.getText(DOC_NAME); // Get a Y.Text type by name

      ydocRef.current = ydoc;
      yTextRef.current = yText;

      // Connect to the WebSocket provider
      const provider = new WebsocketProvider(
        WEBSOCKET_URL,
        DOC_NAME, // Room name for collaboration
        ydoc, 
        { connect: true }
      );
      providerRef.current = provider;

      // Listen for changes on the Y.Text and update React state
      yText.observe(event => {
        // Yjs sends fine-grained events, but for a textarea, we often just grab the whole text
        setText(yText.toString());
      });

      // Initialize the textarea with current content
      setText(yText.toString());
    }

    return () => {
      // Clean up provider connection on unmount
      if (providerRef.current) {
        providerRef.current.destroy();
      }
    };
  }, []);

  // Handle changes from the textarea and apply them to Y.Text
  const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
    if (yTextRef.current) {
      const newText = e.target.value;
      // Update Y.Text. Yjs handles the diffing and synchronization.
      // For a simple textarea, we replace the whole content.
      // For richer editors, you'd use insert/delete at specific positions.
      yTextRef.current.delete(0, yTextRef.current.length);
      yTextRef.current.insert(0, newText);
    }
  }, []);

  return (
    <div>
      <h1>Collaborative Document</h1>
      <textarea
        ref={textareaRef}
        value={text}
        onChange={handleChange}
        rows={10}
        cols={80}
        placeholder="Start typing here..."
      />
      <p>Open this page in multiple tabs or browsers to see real-time collaboration!</p>
    </div>
  );
}

export default CollaborativeEditor;

This simple example demonstrates the core idea: you get a Y.Doc, retrieve a collaborative type like Y.Text from it, and then bind your UI to that type. When you update the Y.Text (e.g., in handleChange), Yjs automatically serializes the changes and sends them via the WebsocketProvider to all other connected clients. When other clients receive updates, yText.observe fires, and your UI updates. Crucially, if two users type at the same position simultaneously, Yjs (using its underlying CRDT logic) will merge those changes deterministically without corruption.

Deeper Dive into Yjs Types

Y.Text is great for plain text, but Yjs offers more:

  • Y.Map: For collaborative objects (key-value pairs). Think of a shared JSON object where users can add, remove, or modify properties.
  • Y.Array: For collaborative lists. Users can insert, delete, or move items, and Yjs maintains order and consistency.
  • Y.XmlFragment / Y.XmlElement: For collaborative rich text or structured content. This is what powers complex editors like ProseMirror or TipTap when integrated with Yjs, allowing for collaborative editing of paragraphs, formatting, embedded elements, etc.

Each of these types exposes methods similar to their JavaScript counterparts (set, get, delete, insert, splice), but these operations are now collaborative and conflict-free.

The Trade-offs and Considerations

Yjs is powerful, but it's not a silver bullet. Here are a few things to keep in mind:

  1. Complexity under the hood: While Yjs simplifies your code, the underlying CRDT algorithms are complex. Debugging subtle synchronization issues can be challenging, though y-websocket is quite robust.
  2. Server-side persistence: Yjs focuses on real-time synchronization between clients. If you need to persist your document state server-side (which you almost certainly do for any serious application), you'll need to save the Y.Doc state periodically. Yjs provides Y.encodeStateAsUpdate and Y.applyUpdate for this, allowing you to save and load binary document states.
  3. Data model shift: You might need to adjust your application's data model to leverage Yjs's collaborative types effectively. Instead of plain objects or arrays, you'll be working with Y.Map, Y.Array, etc. This usually means a small wrapper or adapter layer if you're integrating with an existing codebase.
  4. Operational overhead: Running a WebSocket server for y-websocket (or a more advanced provider like y-leveldb for persistent storage) adds a component to your infrastructure. However, y-websocket is quite lightweight and can run alongside your existing backend services.

Despite these, the benefits of automatic conflict resolution and a robust, battle-tested synchronization engine often outweigh the setup cost. Yjs frees you from reinventing a wheel that's incredibly difficult to get right.

Wrapping up

If you're building any application where multiple users need to interact with the same live data – whether it's a document editor, a whiteboard, a collaborative form, or even a real-time analytics dashboard – Yjs is a tool you need to seriously consider. It fundamentally changes how you approach shared state, moving the heavy lifting of synchronization and conflict resolution into a well-engineered library.

My advice: Clone the y-websocket server (it's a small Node.js project) and run it locally. Then, adapt the CollaborativeEditor component I showed above into your own React project. Open two browser tabs to localhost:3000 (or wherever your app runs) and start typing. Watch the magic happen. You'll quickly see how powerful it is to have truly shared, conflict-free data types at your disposal.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕