Building a Local-First Frontend State Sync with Conflict-Free Replicated Data Types (CRDTs)
Building a Local-First Frontend State Sync with Conflict-Free Replicated Data Types (CRDTs)
In modern web apps, users expect instant, offline-capable experiences. A local-first approach lets the app work offline, sync changes when online, and gracefully handle conflicts. This tutorial walks you through designing and implementing a frontend state management layer powered by CRDTs (Conflict-Free Replicated Data Types) to achieve real-time collaboration, offline resilience, and deterministic reconciliation-without a heavyweight back-end.
What you’ll build
- A small frontend app that manages a shared list of tasks with per-item comments.
- Local-first data store powered by a CRDT (automatically resolves concurrent edits).
- Client-side synchronization via a lightweight, publish-subscribe protocol over WebSocket (server is optional for this tutorial; you can swap in a real sync service later).
- A minimal persistence layer using IndexedDB for offline durability.
- A clean developer experience with deterministic tests and a simple dev server.
Core concepts (quick primer)
- Local-first: operations occur on the client immediately, then sync with others when possible.
- CRDTs: data structures designed so that independent updates can be merged deterministically without conflict resolution logic.
- Convergent state: regardless of update order or network delays, all replicas converge to the same state.
- Conflict-free merging: CRDTs define merge rules so concurrent edits don’t overwrite each other in unpredictable ways.
Tech stack (suggested)
- Frontend: TypeScript, React (or your favorite library)
- Storage: IndexedDB (via idb or Dexie)
- CRDT library: Yjs (a mature CRDT framework for collaborative apps) or Automerge. We’ll use Yjs in this guide for efficiency and ecosystem.
- Synchronization: WebSocket or WebRTC data channels (simplified in this tutorial)
- Runtime: Vite for a fast dev server
Getting started: project scaffold
1) Initialize the project
- Create a new directory and initialize a minimal React + TypeScript app with Vite.
- Install dependencies: react, react-dom, typescript, yjs, ywebsocket (optional), idb.
2) Project layout
- src/
- index.html
- main.tsx
- App.tsx
- CRDTStore.ts
- persistence.ts
- sync.ts
- components/
- TaskBoard.tsx
- types.ts
3) Type definitions
- Define the data model you’ll store in the CRDT:
- A list of tasks, each task with id, text, status, and an array of comments.
- Comments can be a per-task list.
- Yjs uses a document with maps and arrays. We’ll map our domain to Yjs structures.
Code patterns and step-by-step implementation
Step A: Set up the CRDT document
- Create a CRDT document and a shared root map for tasks.
- Represent the task list as a Y.Array of task objects, where each task is a Y.Map with fields.
Example: CRDTStore.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IDBKeyval } from './persistence';
import { IndexeddbPersistence } from 'y-indexeddb'; // optional if you prefer it
type TaskStatus = 'todo' | 'in-progress' | 'done';
export type Task = {
id: string;
text: string;
status: TaskStatus;
comments: string[];
};
export class CRDTStore {
private doc: Y.Doc;
public tasks: Y.Array>;
constructor() {
this.doc = new Y.Doc();
// Root-level map to hold metadata and task list
const root = this.doc.getMap('root');
// Use an array to store tasks
if (!root.get('tasks')) {
root.set('tasks', new Y.Array<Y.Map<any>>());
}
this.tasks = root.get('tasks') as Y.Array<Y.Map<any>>;
// Optional: bind to a WebSocket provider for real sync
// const provider = new WebsocketProvider('wss://your-signal-server', 'room-id', this.doc);
// Optional: use IndexedDB persistence with y-indexeddb
// new IndexeddbPersistence(this.doc, 'crdt-demo');
}
// Helpers to read current view as plain TS objects
getTasks(): Task[] {
const arr: Task[] = [];
this.tasks.forEach((taskMap) => {
arr.push({
id: taskMap.get('id'),
text: taskMap.get('text'),
status: taskMap.get('status'),
comments: taskMap.get('comments') || [],
} as Task);
});
// Sort by id or insertion order if you want deterministic UI
return arr;
}
// Create a new task
addTask(text: string) {
const t = new Map();
const id = crypto.randomUUID();
t.set('id', id);
t.set('text', text);
t.set('status', 'todo');
t.set('comments', []);
this.tasks.push([t]);
}
// Update task fields
updateTask(id: string, patch: Partial) {
const idx = this.findIndexById(id);
if (idx < 0) return;
const taskMap = this.tasks.get(idx) as Y.Map;
if (patch.text !== undefined) taskMap.set('text', patch.text);
if (patch.status !== undefined) taskMap.set('status', patch.status);
if (patch.comments !== undefined) taskMap.set('comments', patch.comments);
}
// Add a comment to a task
addComment(id: string, comment: string) {
const idx = this.findIndexById(id);
if (idx < 0) return;
const taskMap = this.tasks.get(idx) as Y.Map;
const comments = taskMap.get('comments') as string[];
comments.push(comment);
taskMap.set('comments', comments);
}
private findIndexById(id: string): number {
let idx = -1;
this.tasks.forEach((t, i) => {
if ((t as Y.Map).get('id') === id) idx = i;
});
return idx;
}
}
Notes:
- Yjs stores data as observable structures. You can attach event listeners to this.tasks to react to changes.
- Using crypto.randomUUID() requires modern runtimes. If unavailable, implement UUID generation.
Step B: Persistence layer
- Persist the CRDT state to IndexedDB so offline changes survive page refreshes.
- We’ll implement a minimal save/load using JSON dumps of the entire doc state.
Example: persistence.ts
import * as Y from 'yjs';
export async function saveDoc(doc: Y.Doc, key: string = 'crdt-doc') {
const state = Y.encodeStateVector(doc);
// Simpler: save entire document as a JSON snapshot
const blob = new Blob([JSON.stringify(serializeDoc(doc))], { type: 'application/json' });
// Use a simple in-browser storage; you could swap to IndexedDB
localStorage.setItem(key, await blobToDataUrl(blob));
}
export async function loadDoc(doc: Y.Doc, key: string = 'crdt-doc') {
const data = localStorage.getItem(key);
if (!data) return;
try {
const parsed = JSON.parse(data);
// Apply to doc (requires custom deserialization)
deserializeDoc(doc, parsed);
} catch {
// ignore
}
}
function serializeDoc(doc: Y.Doc): any {
// Implement a shallow serialization for demo
const root = doc.getMap('root');
const tasks = root.get('tasks') as any[];
return {
tasks: tasks.map((t) => ({
id: t.get('id'),
text: t.get('text'),
status: t.get('status'),
comments: t.get('comments'),
})),
};
}
function deserializeDoc(doc: Y.Doc, data: any) {
const root = doc.getMap('root');
const tasksArr = data?.tasks || [];
const array = new Y.Array();
tasksArr.forEach((t: any) => {
const m = new Y.Map();
m.set('id', t.id);
m.set('text', t.text);
m.set('status', t.status);
m.set('comments', t.comments);
array.push([m]);
});
root.set('tasks', array);
}
function blobToDataUrl(blob: Blob): Promise {
return new Promise((res) => {
const reader = new FileReader();
reader.onload = () => res(reader.result as string);
reader.readAsDataURL(blob);
});
}
export default { saveDoc, loadDoc };
Step C: UI components
- Build a TaskBoard that renders tasks, allows adding tasks, editing text/status, and adding comments.
- Bind UI events to CRDTStore methods.
- Subscribe to CRDT document changes to re-render.
Example: TaskBoard.tsx
import React, { useEffect, useState } from 'react';
import { CRDTStore } from '../CRDTStore';
import { Task } from '../types';
type Props = {
store: CRDTStore;
};
export const TaskBoard: React.FC = ({ store }) => {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState('');
useEffect(() => {
// Initialize from store
setTasks(store.getTasks());
// Listen for changes (Yjs events)
const onChange = () => setTasks(store.getTasks());
// You'd typically subscribe to doc events here
// e.g., store.doc.on('update', onChange);
return () => {
// detach
};
}, [store]);
function addTask() {
if (!newTask.trim()) return;
store.addTask(newTask.trim());
setNewTask('');
}
function update(id: string, patch: Partial) {
store.updateTask(id, patch);
}
function addComment(id: string, c: string) {
if (!c.trim()) return;
store.addComment(id, c.trim());
}
return (
Local-First Task Board (CRDT)
placeholder="Add a task..."
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
/>
Add Task
<div style={{ marginTop: 16 }}>
{tasks.map((t) => (
<div key={t.id} style={{ border: '1px solid #ddd', padding: 8, marginBottom: 8 }}>
<input
value={t.text}
onChange={(e) => update(t.id, { text: e.target.value })}
style={{ width: '60%' }}
/>
<select value={t.status} onChange={(e) => update(t.id, { status: e.target.value as any })}>
<option value="todo">Todo</option>
<option value="in-progress">In progress</option>
<option value="done">Done</option>
</select>
<div style={{ marginTop: 8 }}>
<strong>Comments</strong>
<CommentList
task={t}
onAdd={(c) => addComment(t.id, c)}
/>
</div>
</div>
))}
</div>
</div>
);
};
function CommentList({ task, onAdd }: { task: Task; onAdd: (c: string) => void }) {
const [c, setC] = useState('');
return (
-
{task.comments.map((cm, idx) => (
- {cm} ))}
placeholder="Add a comment..."
value={c}
onChange={(e) => setC(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onAdd(c);
setC('');
}
}}
/>
{ onAdd(c); setC(''); }}>Comment
);
}
Step D: App bootstrapping
- Create an App.tsx that instantiates CRDTStore, loads persisted state, and renders TaskBoard.
Example: App.tsx
import React, { useEffect, useState } from 'react';
import { CRDTStore } from './CRDTStore';
import { TaskBoard } from './components/TaskBoard';
import { Task } from './types';
import { saveDoc, loadDoc } from './persistence';
export const App: React.FC = () => {
const [store] = useState(() => new CRDTStore());
const [ready, setReady] = useState(false);
useEffect(() => {
// Load persisted state
async function boot() {
// loadDoc(store.doc); // if you wired doc to persistence
setReady(true);
}
boot();
}, [store]);
useEffect(() => {
// Optional: persist on changes
// saveDoc(store.doc);
}, [store]);
if (!ready) return
Loading...;return (
CRDT Frontend Tutorial
);
};
export default App;
Step E: Dev server and run script
- In package.json, add scripts to start Vite. { "name": "crdt-frontend-tutorial", "version": "0.1.0", "private": true, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview port 5173" }, "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0", "typescript": "^5.0.0", "yjs": "^13.0.0", "y-websocket": "^1.0.0" }, "devDependencies": { "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "vite": "^4.0.0" } }
Notes on real synchronization
- For a production-grade offline-first app, you’d replace the optional WebSocket provider with a real sync channel, manage rooms, and handle authentication. Yjs works well with y-webrtc for P2P sync or with a central signaling server plus WebSocket for a hosted backend.
- You’ll also want conflict resolution tied to your UI: show users that a concurrent edit happened and let them accept or review changes.
Testing strategy (quick wins)
- Unit tests for CRDTStore methods:
- addTask creates a task with a unique id, default status, and empty comments.
- updateTask updates fields without overwriting other fields.
- addComment appends to the per-task comments.
- Integration tests mock a second client by cloning the document, applying a patch, and ensuring a merged result matches expectations.
- Snapshot tests for UI render after a sequence of operations.
- End-to-end test skeleton: simulate offline edits, go online, ensure convergence.
Accessibility and UX tips
- Provide clear status indicators: online/offline, synchronization state, and last sync timestamp.
- Make task text editable with proper labels, accessible keyboard navigation, and aria-expanded state for comment sections.
- Debounce server writes and reflect local updates immediately.
Performance and scalability notes
- For hundreds to thousands of tasks, CRDTs still perform well; however, you’ll want to:
- Use virtualization (e.g., react-window) if rendering large lists.
- Keep per-item data localized to minimize re-renders.
- Partition large documents into multiple CRDT nodes if needed.
Security considerations
- Client-side CRDT data is trusted only by the client. Always apply server-side validation if you introduce trusted backends.
- Encrypt sensitive data before persisting or syncing if you handle private information.
Illustration: how CRDTs help in practice
- Imagine two users editing the same task text at the same time on different devices. Without CRDTs, one edit might overwrite the other. With CRDTs (Yjs), both edits are represented as separate operations and merged. The final text reflects both contributions in a deterministic way, and the app converges to the same state for both users once synchronization happens.
Next steps and how to adapt
- If you already have an existing frontend, you can integrate Yjs by replacing your in-memory state with a Yjs document and mapping your domain models to Yjs structures.
- To scale to real multi-user collaboration, wire up a proper signaling server and a durable backend store for snapshots, while letting CRDTs handle real-time merges on the client side.
Would you like this tutorial adapted to a specific framework (e.g., React with Redux, Svelte, or Vue), or to target a particular backend (WebSocket server, or WebRTC-based mesh)? I can tailor the code samples to fit your preferred stack and add a runnable repo starter.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)