Skip to main content
Complete Steps 1-2 on the Core setup page before continuing. Those steps install dependencies and initialize Velt.
The XML store requires direct Yjs manipulation, so you also need the yjs package:
npm i yjs

Setup

Step 1: Create a CRDT XML store

Use the useStore hook with type: 'xml' to create a CRDT store backed by a Yjs Y.XmlFragment. Unlike text/map/array stores, the XML store does not use the hook’s update() method. All mutations must go through Yjs APIs directly via store.getXml().
import { useStore } from '@veltdev/crdt-react';
import * as Y from 'yjs';
import { useEffect, useRef } from 'react';

function Component() {
  const xmlRef = useRef<Y.XmlFragment | null>(null);

  const {
    store,
    isLoading,
    error,
  } = useStore<string>({
    storeId: 'my-xml-store',
    type: 'xml',
  });

  // When store is ready, get the XML fragment and set up the tree
  useEffect(() => {
    if (!store) return;

    const xml = store.getXml() as unknown as Y.XmlFragment | null;
    if (!xml) return;
    xmlRef.current = xml;

    // Populate with initial content if the document is empty
    if (xml.length === 0) {
      const doc = store.getDoc();
      doc.transact(() => {
        populateInitialContent(xml);
      });
    }
  }, [store]);
}

Step 2: Manipulate the XML tree with Yjs APIs

Use Y.XmlElement and Y.XmlFragment APIs to read and modify the tree. Fine-grained Yjs operations (setAttribute, insert, delete) give better CRDT merge behavior than whole-document replacement.
import * as Y from 'yjs';

// Create a new XML element
function createNewElement(tag: string, attributes: Record<string, string>): Y.XmlElement {
  const element = new Y.XmlElement(tag);
  for (const [key, value] of Object.entries(attributes)) {
    element.setAttribute(key, value);
  }
  return element;
}

// Add a new element to the root fragment
function addElement(xml: Y.XmlFragment) {
  const element = createNewElement('item', { id: 'item-1', name: 'New Item' });
  xml.insert(xml.length, [element]);
}

// Find an element by attribute and update it
function updateElementAttribute(xml: Y.XmlFragment, elementId: string, key: string, value: string) {
  const element = findElementById(xml, elementId);
  if (element) {
    element.setAttribute(key, value);
  }
}

// Delete an element from its parent
function deleteElement(parent: Y.XmlFragment | Y.XmlElement, index: number) {
  parent.delete(index, 1);
}

Step 3: Subscribe to real-time changes

For XML stores, use store.subscribe() to listen for changes from all collaborators. Re-read the Y.XmlFragment in the callback to rebuild your in-memory data structure.
useEffect(() => {
  if (!store) return;

  const unsub = store.subscribe(() => {
    if (xmlRef.current) {
      // Re-read the Y.XmlFragment and convert to your data structure
      const updatedData = readXmlFragment(xmlRef.current);
      setData(updatedData);
    }
  });

  return () => unsub();
}, [store]);

Step 4: Save and restore versions (optional)

Create checkpoints and roll back when needed.
The useStore hook exposes version management methods directly:
import { Version } from '@veltdev/crdt-react';

const {
  store,
  saveVersion,
  getVersions,
  getVersionById,
  restoreVersion,
  setStateFromVersion,
} = useStore<string>({
  storeId: 'my-xml-store',
  type: 'xml',
});

// Save a named snapshot of the current state
await saveVersion('Draft v1');

// Retrieve the list of all saved versions
const versions: Version[] = await getVersions();

// Restore the store to a previously saved version
await restoreVersion(versionId);
const version = await getVersionById(versionId);
if (version) {
  await setStateFromVersion(version);
}

Step 5: Initial content with forceResetInitialContent (optional)

For XML stores, initial content is applied manually by checking if the Y.XmlFragment is empty. To force-reset, clear the fragment and re-populate inside a Yjs transaction.
const xml = store.getXml() as unknown as Y.XmlFragment | null;
if (!xml) return;

const doc = store.getDoc();

// Force-reset: clear existing content and re-populate
doc.transact(() => {
  if (xml.length > 0) xml.delete(0, xml.length);
  populateInitialContent(xml);
});

Complete Example

A complete collaborative outline editor built with useStore and direct Yjs manipulation:OutlineNode type and Yjs helpers
Yjs Helpers
import * as Y from 'yjs';

interface OutlineNode {
  id: string;
  text: string;
  collapsed: boolean;
  children: OutlineNode[];
}

// Generate a random ID
function generateId(): string {
  return Math.random().toString(36).substring(2, 9);
}

// Convert a Y.XmlFragment to an array of OutlineNode objects
function xmlFragmentToNodes(container: Y.XmlFragment | Y.XmlElement): OutlineNode[] {
  const nodes: OutlineNode[] = [];
  for (let i = 0; i < container.length; i++) {
    const child = container.get(i);
    if (child instanceof Y.XmlElement && child.nodeName === 'node') {
      nodes.push({
        id: child.getAttribute('id') || generateId(),
        text: child.getAttribute('text') || '',
        collapsed: child.getAttribute('collapsed') === 'true',
        children: xmlFragmentToNodes(child),
      });
    }
  }
  return nodes;
}

// Create a Y.XmlElement from an OutlineNode
function createXmlElement(node: OutlineNode): Y.XmlElement {
  const el = new Y.XmlElement('node');
  el.setAttribute('id', node.id);
  el.setAttribute('text', node.text);
  el.setAttribute('collapsed', String(node.collapsed));
  for (const child of node.children) {
    el.insert(el.length, [createXmlElement(child)]);
  }
  return el;
}

// Populate a Y.XmlFragment with an array of OutlineNode objects
function populateTree(fragment: Y.XmlFragment, nodes: OutlineNode[]): void {
  for (const node of nodes) {
    fragment.insert(fragment.length, [createXmlElement(node)]);
  }
}

// Find a Y.XmlElement by its 'id' attribute
function findElementById(
  container: Y.XmlFragment | Y.XmlElement,
  id: string
): Y.XmlElement | null {
  for (let i = 0; i < container.length; i++) {
    const child = container.get(i);
    if (child instanceof Y.XmlElement) {
      if (child.getAttribute('id') === id) return child;
      const found = findElementById(child, id);
      if (found) return found;
    }
  }
  return null;
}

// Find an element and its parent for deletion
function findElementWithParent(
  container: Y.XmlFragment | Y.XmlElement,
  id: string
): { element: Y.XmlElement; parent: Y.XmlFragment | Y.XmlElement; index: number } | null {
  for (let i = 0; i < container.length; i++) {
    const child = container.get(i);
    if (child instanceof Y.XmlElement) {
      if (child.getAttribute('id') === id) {
        return { element: child, parent: container, index: i };
      }
      const found = findElementWithParent(child, id);
      if (found) return found;
    }
  }
  return null;
}
OutlineEditor component
Complete Implementation
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useStore, Version } from '@veltdev/crdt-react';
import * as Y from 'yjs';

const DEFAULT_INITIAL_NODES: OutlineNode[] = [
  {
    id: 'n1', text: 'Getting Started', collapsed: false,
    children: [
      { id: 'n1a', text: 'Installation', collapsed: false, children: [] },
      { id: 'n1b', text: 'Quick Start Guide', collapsed: false, children: [] },
    ],
  },
  {
    id: 'n2', text: 'Core Concepts', collapsed: false,
    children: [
      { id: 'n2a', text: 'Real-time Collaboration', collapsed: false, children: [] },
      { id: 'n2b', text: 'CRDT Types', collapsed: false, children: [] },
    ],
  },
];

export const OutlineEditor = () => {
  const [newNodeText, setNewNodeText] = useState('');
  const [versionName, setVersionName] = useState('');
  const [versions, setVersions] = useState<Version[]>([]);
  const [nodes, setNodes] = useState<OutlineNode[]>([]);
  const xmlRef = useRef<Y.XmlFragment | null>(null);

  // Use the useStore hook — handles initialization automatically
  const {
    store,
    saveVersion: storeSaveVersion,
    getVersions: storeGetVersions,
    restoreVersion: storeRestoreVersion,
    getVersionById,
    setStateFromVersion,
  } = useStore<string>({
    storeId: 'my-outline-store',
    type: 'xml',
  });

  // When store is ready, get the XML fragment and set up the tree
  useEffect(() => {
    if (!store) return;

    const xml = store.getXml() as unknown as Y.XmlFragment | null;
    if (!xml) return;
    xmlRef.current = xml;

    // Populate with initial content if the document is empty
    if (xml.length === 0 && DEFAULT_INITIAL_NODES.length > 0) {
      const doc = store.getDoc();
      doc.transact(() => {
        populateTree(xml, DEFAULT_INITIAL_NODES);
      });
    }

    // Seed React state with current tree
    setNodes(xmlFragmentToNodes(xml));

    // Subscribe to all future changes (local and remote)
    const unsub = store.subscribe(() => {
      if (xmlRef.current) {
        setNodes(xmlFragmentToNodes(xmlRef.current));
      }
    });

    return () => unsub();
  }, [store]);

  // Tree operations via direct Yjs manipulation
  const addRootNode = useCallback((text: string) => {
    const xml = xmlRef.current;
    if (!xml) return;
    const newNode: OutlineNode = {
      id: generateId(),
      text,
      collapsed: false,
      children: [],
    };
    xml.insert(xml.length, [createXmlElement(newNode)]);
  }, []);

  const updateNodeText = useCallback((nodeId: string, newText: string) => {
    const xml = xmlRef.current;
    if (!xml) return;
    const el = findElementById(xml, nodeId);
    if (el) {
      el.setAttribute('text', newText);
    }
  }, []);

  const addChildNode = useCallback((parentId: string) => {
    const xml = xmlRef.current;
    if (!xml) return;
    const parentEl = findElementById(xml, parentId);
    if (!parentEl) return;
    parentEl.setAttribute('collapsed', 'false');
    const newChild: OutlineNode = {
      id: generateId(),
      text: 'New item',
      collapsed: false,
      children: [],
    };
    parentEl.insert(parentEl.length, [createXmlElement(newChild)]);
  }, []);

  const deleteNode = useCallback((nodeId: string) => {
    const xml = xmlRef.current;
    if (!xml) return;
    const result = findElementWithParent(xml, nodeId);
    if (result) {
      result.parent.delete(result.index, 1);
    }
  }, []);

  // Version management
  const refreshVersions = useCallback(async () => {
    const v = await storeGetVersions();
    setVersions(v);
  }, [storeGetVersions]);

  useEffect(() => {
    if (store) refreshVersions();
  }, [refreshVersions, store]);

  const handleAddNode = (e: React.FormEvent) => {
    e.preventDefault();
    if (newNodeText.trim()) {
      addRootNode(newNodeText.trim());
      setNewNodeText('');
    }
  };

  const handleSaveVersion = async (e: React.FormEvent) => {
    e.preventDefault();
    if (versionName.trim()) {
      await storeSaveVersion(versionName.trim());
      setVersionName('');
      await refreshVersions();
    }
  };

  const handleRestoreVersion = async (versionId: string) => {
    await storeRestoreVersion(versionId);
    const version = await getVersionById(versionId);
    if (version) {
      await setStateFromVersion(version);
    }
    await refreshVersions();
  };

  return (
    <div>
      <form onSubmit={handleAddNode}>
        <input
          type="text"
          value={newNodeText}
          onChange={(e) => setNewNodeText(e.target.value)}
          placeholder="New node text..."
        />
        <button type="submit">Add Node</button>
      </form>

      <ul>
        {nodes.map((node) => (
          <li key={node.id}>
            <input
              type="text"
              value={node.text}
              onChange={(e) => updateNodeText(node.id, e.target.value)}
            />
            <button onClick={() => addChildNode(node.id)}>+</button>
            <button onClick={() => deleteNode(node.id)}>&times;</button>
          </li>
        ))}
      </ul>

      <h3>Versions</h3>
      <form onSubmit={handleSaveVersion}>
        <input
          type="text"
          value={versionName}
          onChange={(e) => setVersionName(e.target.value)}
          placeholder="Version name..."
        />
        <button type="submit">Save Version</button>
      </form>

      <ul>
        {versions.map((version) => (
          <li key={version.versionId}>
            <span>{version.versionName}</span>
            <button onClick={() => handleRestoreVersion(version.versionId)}>Restore</button>
          </li>
        ))}
      </ul>
    </div>
  );
};