
Yjs: Collaborative Editing Beyond the Framework
Building real-time collaborative features is notoriously complex, often leading to bespoke, brittle solutions. Yjs offers a robust, framework-agnostic approach to shared data types, making it simpler to add collaborative editing to any application.
by Sunil Band
The Nightmare of Real-time Collaboration
Every time a product manager asks for "Google Docs-style collaboration," a small part of my soul dies. It's not because I don't want to build it; it's because the implementation is usually a terrifying rabbit hole of custom operational transformation (OT) algorithms, WebSocket management, and state reconciliation logic that inevitably breaks in subtle, impossible-to-debug ways. Most frameworks give you useState or useReducer, which are great for local state, but fall apart when you need multiple users to edit the same document simultaneously without constant conflicts or a full page refresh.
Traditional approaches often involve sending full document updates, which is inefficient and conflict-prone. Or, you try to roll your own OT, which is an academic discipline in itself. What we need is a robust, well-tested foundation that handles the messy parts of shared data types and conflict resolution for us, letting us focus on the UI and business logic. This is where Yjs shines.
What Yjs Actually Does
Yjs is a CRDT (Conflict-free Replicated Data Type) implementation that provides shared data structures like text, arrays, and maps. Think of it as useState for a distributed system. Instead of focusing on how to merge changes, CRDTs are designed such that concurrent updates to multiple replicas of the data can be merged automatically and deterministically, without requiring a central authority and without losing data. This is a game-changer for collaboration.
It works by representing changes as a series of small, idempotent operations. When two users make changes simultaneously, Yjs doesn't try to guess which one "wins." Instead, it applies both changes in a way that always results in the same final state across all clients, regardless of the order they're received. This fundamental property simplifies collaborative editing immensely, moving the complexity out of your application code and into a well-engineered library.
Beyond Simple Text Editing
While Yjs is famously used for collaborative text editors (like TipTap or Remirror, which often integrate Yjs), its power extends far beyond that. Its core is about shared data types. This means you can build collaborative whiteboards, spreadsheets, diagramming tools, or even real-time dashboards where multiple users are updating components or configurations. The Y.Map and Y.Array types are just as powerful as Y.Text.
Let's consider a simple example: a shared to-do list where multiple users can add, remove, and complete tasks. Without Yjs, you'd be grappling with race conditions if two users tried to toggle the same task or add a new one concurrently. With Yjs, this becomes almost trivial.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// Create a Yjs document
const ydoc = new Y.Doc();
// Connect to a Yjs WebSocket provider (could be a local dev server or a public one)
// This handles the network synchronization for us
const wsProvider = new WebsocketProvider(
'ws://localhost:1234', // Or 'wss://demos.yjs.dev'
'my-todo-list-room', // A unique room name for this document
ydoc
);
// Get a Y.Map to store our todos. We'll use IDs as keys and task objects as values.
const todos = ydoc.getMap('todos');
// --- Client 1 --- (imagine this running in browser tab 1)
// Add a new todo
function addTodo(id: string, text: string) {
ydoc.transact(() => { // Use transact for multiple changes that should be atomic
todos.set(id, { text, completed: false, createdAt: Date.now() });
console.log(`[Client 1] Added todo: ${text}`);
});
}
// Toggle completion status
function toggleTodo(id: string) {
ydoc.transact(() => {
const todo = todos.get(id);
if (todo) {
// Y.Map values are immutable, so we create a new object
todos.set(id, { ...todo, completed: !todo.completed });
console.log(`[Client 1] Toggled todo '${todo.text}'. Completed: ${todos.get(id)?.completed}`);
}
});
}
// Delete a todo
function deleteTodo(id: string) {
ydoc.transact(() => {
const todo = todos.get(id);
if (todo) {
todos.delete(id);
console.log(`[Client 1] Deleted todo: ${todo.text}`);
}
});
}
// Listen for changes from any client
todos.observeDeep(events => {
console.log('[Client 1] Todos changed. Current state:');
todos.forEach((value, key) => console.log(` - ${key}: ${value.text} (Completed: ${value.completed})`));
});
// Simulate some actions after a delay
setTimeout(() => addTodo('task-1', 'Buy groceries'), 1000);
setTimeout(() => addTodo('task-2', 'Walk the dog'), 2000);
setTimeout(() => toggleTodo('task-1'), 3000);
setTimeout(() => deleteTodo('task-2'), 4000);
// --- Client 2 --- (imagine this running in browser tab 2, connected to the same room)
// ... (similar setup code for ydoc and wsProvider)
// Client 2 also adds and toggles things, concurrently with client 1
// The beauty is that Yjs automatically resolves these concurrent changes
// For example, if Client 2 adds 'task-3' at 2500ms, both clients will see 'task-3' appear
// And if Client 2 tries to toggle 'task-1' at 3200ms, it will also work and be consistent.
// Let's simulate Client 2 adding a task and toggling one that Client 1 also touched
// This will be resolved by Yjs without any explicit merge logic in our app
setTimeout(() => addTodo('task-3', 'Pay bills'), 2500); // Concurrently with client 1's actions
setTimeout(() => toggleTodo('task-1'), 3500); // Client 2 toggles task-1 after client 1 didThis example uses y-websocket, a simple WebSocket provider for Yjs, which you can run locally with npx y-websocket. It's incredibly straightforward. The observeDeep method is crucial here; it gives you granular change events, so you can efficiently update your UI without re-rendering everything. This pattern keeps your React components (or Vue, or vanilla JS) completely decoupled from the real-time synchronization logic.
The Plumbing: Providers and Awareness
Yjs itself is just the data model. To make it collaborative, you need a provider that handles the network transport. y-websocket is a common choice for quick demos or smaller projects, but there are other options like y-indexeddb for offline persistence, y-webrtc for peer-to-peer, or custom providers if you want to integrate with your existing backend (e.g., Kafka, Pub/Sub, custom WebSockets).
Beyond just syncing data, Yjs also has a concept of awareness. This is for ephemeral, non-document state like cursor positions, selection ranges, or whether a user is currently online. It's often managed by the provider (like y-websocket) and allows you to build features like seeing other users' cursors in real-time. It's a separate mechanism from the CRDTs for document content, optimized for low-latency, transient updates.
Trade-offs and Considerations
While Yjs is powerful, it's not a silver bullet. Here are a few things to keep in mind:
- Learning Curve for CRDTs: While Yjs abstracts away much of the complexity, understanding the mental model of CRDTs and shared types takes a bit of time. You're no longer thinking about just a single source of truth, but rather convergent replicas.
- Data Structure Limitations: Not all data structures are easily represented as CRDTs. Yjs provides robust
Text,Map, andArraytypes, but if you have highly specialized, graph-like data or need complex transactional guarantees across multiple documents, you might need to model your data carefully or combine Yjs with other solutions. - Backend Integration: For production, you'll likely want to run your own
y-websocketserver or integrate Yjs with your existing backend infrastructure. This means managing a WebSocket server, handling authentication, and potentially persisting document state to a database. While Yjs provides the primitives, you still need to build the full application around it. - Performance with Large Documents: While CRDTs are efficient, very large documents with extremely frequent, granular updates can still generate a significant amount of network traffic. Yjs includes optimizations like encoding diffs, but it's something to monitor.
Yjs in a React Context
Integrating Yjs with React (or any UI framework) is often done through custom hooks. You'd typically create a hook that initializes the Y.Doc and its provider, then exposes the shared Yjs types and a mechanism to observe their changes, triggering React state updates. This keeps your components reactive to collaborative changes.
For example, a useSharedMap hook could look something like this:
import React, { useEffect, useState } from 'react';
import * as Y from 'yjs';
interface UseSharedMapOptions {
ydoc: Y.Doc;
mapName: string;
}
function useSharedMap<T>(options: UseSharedMapOptions) {
const { ydoc, mapName } = options;
const sharedMap = ydoc.getMap<T>(mapName);
const [mapState, setMapState] = useState<Map<string, T>>(() => new Map(sharedMap.entries()));
useEffect(() => {
const observer = (event: Y.YMapEvent<T>, transaction: Y.Transaction) => {
// Only update if changes came from a different source or if it's not a local transaction
// For simplicity, we'll just always update on any change
setMapState(new Map(sharedMap.entries()));
};
sharedMap.observeDeep(observer);
// Cleanup observer on unmount
return () => sharedMap.unobserveDeep(observer);
}, [sharedMap]);
// Wrapper to allow React components to easily modify the Y.Map
const set = (key: string, value: T) => {
ydoc.transact(() => {
sharedMap.set(key, value);
});
};
const remove = (key: string) => {
ydoc.transact(() => {
sharedMap.delete(key);
});
};
return [mapState, set, remove] as const;
}
// Example usage in a React component
// Assumes you have a Y.Doc instance passed down or provided via context
// function TodoList({ ydoc }: { ydoc: Y.Doc }) {
// const [todos, setTodos, removeTodo] = useSharedMap<{ text: string; completed: boolean }>({ ydoc, mapName: 'todos' });
// const handleAddTodo = () => {
// const id = `task-${Date.now()}`;
// setTodos(id, { text: `New task ${todos.size + 1}`, completed: false });
// };
// const handleToggleTodo = (id: string) => {
// const todo = todos.get(id);
// if (todo) setTodos(id, { ...todo, completed: !todo.completed });
// };
// return (
// <div>
// <button onClick={handleAddTodo}>Add Todo</button>
// <ul>
// {Array.from(todos.entries()).map(([id, todo]) => (
// <li key={id}>
// <input
// type="checkbox"
// checked={todo.completed}
// onChange={() => handleToggleTodo(id)}
// />
// <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
// {todo.text}
// </span>
// <button onClick={() => removeTodo(id)}>X</button>
// </li>
// ))}
// </ul>
// </div>
// );
// }This hook demonstrates how you'd bridge the Yjs world with React's state management. Any change to sharedMap (either locally or from another client via the wsProvider) triggers the observer, which then updates the mapState, causing your React component to re-render with the latest collaborative data. This pattern keeps the collaborative logic encapsulated and reusable.
Wrapping up
Building collaborative features is no longer the domain of distributed systems PhDs. Yjs provides a robust, battle-tested foundation for shared data types, abstracting away the gnarly details of conflict resolution. It allows you to focus on the user experience rather than reinventing the wheel for real-time synchronization.
The next time you're faced with a requirement for real-time, multi-user editing, don't reach for bespoke WebSocket magic. Instead, explore Yjs. Start by spinning up a local y-websocket server (it's literally npx y-websocket), then integrate a Y.Doc and a Y.Map into a simple React component, observing changes to see the magic happen. You'll be surprised how quickly you can get a truly collaborative experience working.

Internationalization in Next.js: Beyond the Basic `i18n.js` File
Setting up i18n in Next.js often starts with a simple JSON file, but real-world applications quickly outgrow that. `next-intl` offers a robust solution that integrates deeply with Next.js features, including Server Components, to manage translations more effectively and avoid common pitfalls.

Beyond the Canvas: When Your Frontend Becomes an AI Art Studio
Stable Diffusion, Midjourney, DALL-E 3 — these names are everywhere. But for frontend developers, the interaction often stops at calling an API or embedding a widget. InvokeAI, however, presents a different paradigm: bringing the full power of an AI art engine directly into a sophisticated web appli

Beyond the Marketing: When a No-Code Editor Becomes Your Component Playground
I've been wary of 'no-code' tools. They often promise the moon but deliver a walled garden, abstracting away too much and leaving you stranded when you need real customizability. GrapesJS, however, isn't just another drag-and-drop editor; it's a web builder framework. This distinction fundamentally


















