State Sync In A Visual Builder

Undo, redo, multi-user editing, and real-time sync inside a visual app-builder we shipped at the creator economy platform. What I shipped, what bit me, and what I'd do differently.

The visual app-builder we shipped at the creator economy platform was a React and TypeScript designer creators used to ship their own branded mobile apps without code. It landed well with creators and pulled retention up noticeably in the months after launch. What the changelog didn’t show was the six weeks I spent wrestling with the state layer, mostly losing.

Drag a component, bind it to data, restyle it, preview it on a phone frame. Two creators on the same app, both editing at once. Cmd+Z works. Cmd+Shift+Z works. Their devices stay in sync. Easy to describe. Hard to build.

What “state” even means

Naive read, it’s a tree of components and props. Real read, it’s three things stacked.

There’s the canonical document. There’s per-user UI state, the layer you have selected, where the viewport is panned. And there’s per-edit history powering undo and redo. Mix those three and you’ll spend a week debugging why selecting a layer on your machine deselects the same one on your teammate’s.

The most useful call we made early was a hard boundary between “shared document” and “local UI”. Selection isn’t in the document. Viewport isn’t in the document. Panel widths aren’t either. Anything I wouldn’t want to merge stays local. Zustand for that. Yjs for the rest.

Undo with Immer patches

I tried the command pattern first. Command interface, execute and undo, a stack. Fine until your first compound op. A creator selects 8 components, drags them, releases. One undo, eight position changes, plus whatever auto-layout cascade fires. Now imagine they paste a section that contains a list with bound data and fresh IDs. The command class for that is a small novel.

Dropped commands. Leaned on Immer patches.

import { produce, enablePatches, applyPatches, Patch } from "immer";

enablePatches();

export type HistoryEntry = {
  label: string;
  forward: Patch[];
  inverse: Patch[];
  at: number;
};

export type StudioState = {
  pages: Record<string, PageNode>;
  components: Record<string, ComponentNode>;
};

const undoStack: HistoryEntry[] = [];
const redoStack: HistoryEntry[] = [];

export function mutate(
  current: StudioState,
  label: string,
  recipe: (draft: StudioState) => void,
): StudioState {
  let forward: Patch[] = [];
  let inverse: Patch[] = [];

  const next = produce(current, recipe, (p, ip) => {
    forward = p;
    inverse = ip;
  });

  if (forward.length === 0) return current;

  undoStack.push({ label, forward, inverse, at: Date.now() });
  redoStack.length = 0;

  if (undoStack.length > 200) undoStack.shift();
  return next;
}

export function undo(current: StudioState): StudioState {
  const entry = undoStack.pop();
  if (!entry) return current;
  redoStack.push(entry);
  return applyPatches(current, entry.inverse);
}

export function redo(current: StudioState): StudioState {
  const entry = redoStack.pop();
  if (!entry) return current;
  undoStack.push(entry);
  return applyPatches(current, entry.forward);
}

Patches serialize cleanly, so undo history persists across reloads. Compound edits are free, just stack more lines in the same produce recipe. The trade is you lose the semantic label a command class would carry, so we tag every entry with a string the history dropdown shows. “Moved 8 components”. “Inserted Pricing section”. Enough.

Yjs for the shared document

Two creators in the same app at the same time was the actual feature, not future-proofing. We used Yjs. The math is on its side, the React story is decent, and the awareness channel covers cursors and selections without me writing one.

import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { IndexeddbPersistence } from "y-indexeddb";

export function openStudioDoc(appId: string, userId: string, name: string) {
  const ydoc = new Y.Doc();

  const offline = new IndexeddbPersistence(`studio:${appId}`, ydoc);
  const provider = new WebsocketProvider(
    import.meta.env.VITE_COLLAB_WSS,
    `studio:${appId}`,
    ydoc,
    { params: { token: getCollabToken() } },
  );

  provider.awareness.setLocalStateField("user", {
    id: userId,
    name,
    color: pickColor(userId),
  });

  const components = ydoc.getMap<Y.Map<unknown>>("components");
  const pages = ydoc.getMap<Y.Map<unknown>>("pages");

  return { ydoc, provider, offline, components, pages };
}

export function moveComponent(
  components: Y.Map<Y.Map<unknown>>,
  id: string,
  x: number,
  y: number,
) {
  const node = components.get(id);
  if (!node) return;
  node.doc?.transact(() => {
    node.set("x", x);
    node.set("y", y);
  }, "local");
}

The trap: CRDT conflict resolution is automatic, but the resolved result isn’t always what a user expects. Two creators drag the same hero to two positions in the same second. Yjs picks a winner. The loser’s move silently disappears. No merge dialog, because at the CRDT level there is no conflict. We added a small activity strip showing the last few edits so people felt less gaslit when their move evaporated.

Tombstones grow. Yjs keeps deleted ops around so merge algebra holds. On apps creators had iterated for months, binary doc size climbed past 9 MB for a canvas of maybe 250 nodes. We added a server-side snapshot job that compacts to a fresh doc on a schedule and broadcasts the new state vector. Not pretty, but it holds.

Selection lives in Zustand

Selection is not collaborative. Two people can have different things selected. Viewport pan and zoom are not collaborative. Panel widths are not collaborative. All of that lives in Zustand. None of it touches ydoc.

import { create } from "zustand";

type LocalUIState = {
  selectedIds: string[];
  hoveredId: string | null;
  viewport: { x: number; y: number; zoom: number };
  setSelection: (ids: string[]) => void;
  setHover: (id: string | null) => void;
  panBy: (dx: number, dy: number) => void;
};

export const useLocalUI = create<LocalUIState>((set) => ({
  selectedIds: [],
  hoveredId: null,
  viewport: { x: 0, y: 0, zoom: 1 },
  setSelection: (ids) => set({ selectedIds: ids }),
  setHover: (id) => set({ hoveredId: id }),
  panBy: (dx, dy) =>
    set((s) => ({
      viewport: { ...s.viewport, x: s.viewport.x + dx, y: s.viewport.y + dy },
    })),
}));

Tiny. Boring. The only time local state crosses the boundary is when we send selection over the awareness channel so collaborators see each other’s bounding boxes. Ephemeral presence, never enters the document.

Drags and re-renders

A drag fires mousemove every 16 ms. Committing each as a Yjs transaction is wasteful and creates a CRDT op parade. We throttled to 60 ms and only committed the final position on mouseup. Intermediate positions stayed local and re-rendered through Zustand. That dropped op count during drag by about 75% and the doc grew with real edits, not mouse jitter.

import { useEffect, useRef } from "react";

export function useDragCommit(id: string) {
  const lastSent = useRef(0);
  const pending = useRef<{ x: number; y: number } | null>(null);
  const { components } = useStudioDoc();

  useEffect(() => {
    const id = window.setInterval(() => {
      const p = pending.current;
      const now = performance.now();
      if (!p || now - lastSent.current < 60) return;
      moveComponent(components, idForDrag(), p.x, p.y);
      lastSent.current = now;
    }, 16);
    return () => window.clearInterval(id);
  }, [components]);

  return {
    onMove: (x: number, y: number) => (pending.current = { x, y }),
    onEnd: (x: number, y: number) => {
      moveComponent(components, idForDrag(), x, y);
      pending.current = null;
    },
  };
}

The other thing that bought us a lot was a Yjs selector hook comparing by shallow equality. Deep equality was slower than just re-rendering. Sometimes the dumb option wins.

Takeaways

  • Keep selection, viewport, and panel state out of the CRDT. None of it is collaborative.
  • Immer patches beat the command pattern for undo once compound edits show up.
  • CRDT conflict resolution is silent. Add a visible activity feed so users aren’t gaslit when their edit loses.
  • Tombstones grow. Plan a compaction job before the document hits 10 MB.
  • Throttle drags. Don’t broadcast every mousemove.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.