Learn how to build real-time collaborative React apps using Y.js and Zustand. Discover CRDT architecture, custom hooks, and offline-first state management.
When I set out to build a real-time, customizable form builder, I had a clear vision: I wanted it to feel as seamless as Google Docs or Figma. The goal was a drag-and-drop interface where multiple users could jump in, edit simultaneously, and see each other’s changes instantly without stepping on each other’s toes.
As a full-stack developer, I knew the UI components and backend logic wouldn’t be the hardest part. The real boss fight was state synchronization. After evaluating the landscape of real-time collaboration tools, I landed on Y.js.
Here is a deep dive into why Y.js is so powerful, how it fundamentally works, and the Next.js/Zustand code patterns I used to cleanly integrate it into a production-ready React app.
The Synchronization Dilemma: Why CRDTs Win
Before writing any code, you have to choose a synchronization strategy. The traditional approaches have glaring flaws for highly interactive use cases:
Operational Transformation (OT): Used by classic collaborative editors. It works, but it’s incredibly complex to implement correctly from scratch, requires a centralized server authority to resolve conflicts, and makes offline-first workflows a nightmare.
Last-Write-Wins (LWW): Too destructive. If two users edit a complex nested component simultaneously, someone’s data is getting overwritten.
Enter CRDTs (Conflict-free Replicated Data Types). Y.js is a battle-tested CRDT implementation that guarantees consistency without coordination. Each client makes its own decisions, works perfectly offline, and automatically resolves conflicts. It doesn’t just sync data; it mathematically syncs intent.
Even if two users move different components around at the exact same millisecond, the CRDT engine ensures both clients eventually converge to the exact same state without destroying data.
The Architecture: Bridging React and Y.js with Zustand
Integrating a vanilla JavaScript library like Y.js with React’s render cycle requires a solid bridge. You don’t want your app re-rendering every time a remote cursor moves across the screen.
I solved this by making Zustand the heart of the application’s collaboration layer. I built a central store dedicated entirely to holding the Y.js instances and managing the connection lifecycle, keeping the core Y.Doc completely out of the React render cycle.
import { create } from 'zustand'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const useYStore = create((set, get) => ({
ydoc: null,
provider: null,
indexeddbProvider: null,
initialize: (roomName) => {
const ydoc = new Y.Doc()
// 1. Offline support baked in
const indexeddbProvider = new IndexeddbPersistence(roomName, ydoc)
// 2. Real-time sync via WebSocket
const provider = new WebsocketProvider(
process.env.NEXT_PUBLIC_YJS_URL,
roomName,
ydoc
)
set({ ydoc, provider, indexeddbProvider })
},
cleanup: () => {
// Crucial to prevent memory leaks in single-page apps!
const { provider, indexeddbProvider, ydoc } = get()
provider?.destroy()
indexeddbProvider?.destroy()
ydoc?.destroy()
}
}))
In this architecture, the Node.js WebSocket server acts purely as a dumb relay. It doesn’t hold the source of truth; it just broadcasts binary updates between clients.
Taming the “Infinite Loop” Problem
The hardest part of building a collaborative UI is the synchronization loop. If a user types into an input, React updates local state, sends it to Y.js, Y.js fires an observe event, and React updates again. It's a recipe for infinite render loops and sluggish performance.
To solve this, I wrote a custom hook (useSyncProps) that acts as a safe circuit breaker between the UI and the shared CRDT. The secret sauce is a useRef flag that temporarily mutes the incoming observer when the local user is actively typing.
Here is the implementation of that pattern:
export function useSyncProps(componentId, initialValue, propKey) {
const [localValue, setLocalValue] = useState(initialValue)
const isUpdatingFromY = useRef(false)
// Listen to remote changes
useEffect(() => {
const observer = () => {
// The Circuit Breaker: Ignore our own updates!
if (isUpdatingFromY.current) return
const yValue = getYValueFromStore(componentId, propKey)
setLocalValue(yValue)
}
sharedArray.observe(observer)
return () => sharedArray.unobserve(observer)
}, [])
// Push local changes
const updateValue = useCallback((newValue) => {
isUpdatingFromY.current = true // Trip the breaker
setLocalValue(newValue)
updateItemProperty(componentId, propKey, newValue) // Update Y.js
// Reset breaker after a tiny tick
setTimeout(() => { isUpdatingFromY.current = false }, 5)
}, [])
return { value: localValue, updateValue }
}
This ensures the UI feels completely instant (0ms latency for the local user) while smoothly syncing with the rest of the network in the background.
Atomic Updates with Transactions
When a user performs a complex action — like dragging a component across a canvas or reordering an array — you are technically deleting an item and inserting it elsewhere. If you send these as two separate network requests, a remote user might see the item disappear for a split second before reappearing.
Y.js provides a brilliant solution for this: Transactions. By wrapping multiple mutations in a ydoc.transact(), Y.js pauses all observers, applies the changes, and broadcasts them as a single, atomic binary payload.
const moveItem = (fromIndex, toIndex) => {
ydoc.transact(() => {
const item = sharedArray.get(fromIndex)
sharedArray.delete(fromIndex, 1)
sharedArray.insert(toIndex, [item])
}, ydoc.clientID) // Grouped by this specific user ID
}
This approach guarantees that remote clients receive clean, simultaneous UI updates without any flickering or visual glitches.
The Verdict
Y.js takes the intimidating theory of distributed systems and packages it into an incredibly developer-friendly API.
Because it pairs so seamlessly with y-indexeddb, collaborative apps can be inherently offline-first. Furthermore, the built-in awareness protocol makes rendering multiplayer cursors and "who is online" badges straightforward without needing a massive, separate Redis or Pub/Sub architecture.
If you are building a modern web application where the UI is shared, complex, and highly interactive, do not try to reinvent the wheel with manual WebSocket events. Embrace CRDTs, grab Y.js, and focus your energy on building a great product.




Top comments (0)