Skip to content

Repository files navigation

React Offline Kanban

An offline-first kanban prototype that demonstrates a modern approach to building React applications that work fully offline and sync without conflict.

The interesting part is the conflict-resolution layer. Most offline-first apps use a local sync queue plus a last-write-wins resolver. This one uses a CRDT (Yjs) as the data layer, so concurrent edits from multiple tabs or devices merge automatically with no resolver code at all.

The server is a Hocuspocus Node.js process. Persistence runs on Neon Postgres (free tier, no credit card).

Stack

Client

  • React 18 + TypeScript + Vite
  • Redux Toolkit (UI/sync state)
  • Redux Saga (orchestration: online detection, provider status)
  • Yjs (CRDT data layer)
  • y-indexeddb (local persistence)
  • y-websocket (sync provider)
  • Workbox via vite-plugin-pwa (Service Worker, precache)

Server

  • Hocuspocus (Yjs WebSocket backend)
  • @hocuspocus/extension-database (pluggable persistence)
  • pg (Postgres driver)
  • Neon Postgres (managed free tier)

Architecture

┌──────────────────────────────────────────────────────────┐
│ CLIENT (React + Vite)                                    │
│                                                          │
│  UI ─────► Yjs document ──► y-indexeddb (local)          │
│   ▲             │                                        │
│   │             └─► y-websocket provider                 │
│   │                       │                              │
│ RTK + Saga                │                              │
│ (UI state,                │ WebSocket                    │
│  online status)           │                              │
└───────────────────────────┼──────────────────────────────┘
                            │
                            ▼
┌──────────────────────────────────────────────────────────┐
│ SERVER (Node.js + Hocuspocus)                            │
│                                                          │
│  Hocuspocus ──► Database extension ──► pg ──► Neon       │
│                                          (Postgres)      │
└──────────────────────────────────────────────────────────┘

The client connects to the server via WebSocket. The server holds an authoritative Y.Doc per board name, persists it to a Postgres yjs_documents table on every change (debounced). When a client reconnects after going offline, the y-websocket provider syncs the diff automatically. No queue code on either side.

Why CRDT instead of LWW + sync queue?

In a traditional offline-first design, you queue every mutation locally and replay it against the server when you reconnect. If two clients change the same field while both offline, the server picks a winner by timestamp (last-write-wins) and the loser's change is silently dropped.

With Yjs, both clients' operations merge deterministically and losslessly. No queue, no resolver, no conflict UI. The sync protocol is the standard y-protocols exchange:

  1. Client connects, sends its state vector.
  2. Server diffs against its Y.Doc, returns the bytes the client is missing.
  3. Client applies them.
  4. Both sides broadcast subsequent local updates to the other in real time.
  5. If the client disconnects, all edits accumulate locally. On reconnect, the same exchange happens again. No data lost.

Getting started

1. Get a free Postgres

Sign up at neon.tech. No credit card. Free tier is 0.5 GB storage and 100 projects.

Create a new project. Copy the connection string. It looks like:

postgres://USER:PASSWORD@HOST/DBNAME?sslmode=require

2. Configure the server

cp server/.env.example server/.env
# Paste the connection string into server/.env as DATABASE_URL=...

3. Configure the client (optional)

cp .env.example .env
# Default VITE_WS_URL=ws://localhost:1234 works for local dev.

4. Install and run

npm run install:all
npm run dev

The first time you connect, the server creates a yjs_documents table in Neon and the client seeds three sample lists.

Try the offline + merge story

  1. Open http://localhost:5173 in two browser tabs.
  2. Add cards in tab A. Watch them appear in tab B within a second.
  3. Open DevTools → Network → set "Offline" in tab A.
  4. Add and move cards in tab A. UI still works; nothing crashes.
  5. Edit the same card title in tab B at the same time.
  6. Bring tab A back online.
  7. Both edits merge. No data is lost. The CRDT handles it.

Deployment

The server has a Dockerfile suitable for any container host. Tested paths:

  • Render (free web service tier): push the repo, set DATABASE_URL env var, deploy. Cold starts after 15 min idle.
  • Railway / Fly.io / Cloudflare Workers: same pattern.

After deploying the server, point the client at it by setting VITE_WS_URL=wss://your-app.onrender.com and rebuilding.

Project layout

.
├── src/                       # React client
│   ├── app/                   # Redux store + root saga
│   ├── yjs/                   # Y.Doc, provider, CRUD ops
│   ├── features/
│   │   ├── ui/                # UI slice
│   │   ├── sync/              # Sync slice + saga
│   │   └── board/             # Board, List, Card components
│   ├── sw/                    # Service Worker registration
│   ├── App.tsx
│   └── main.tsx
├── server/                    # Hocuspocus server
│   ├── src/
│   │   ├── index.ts           # Server bootstrap
│   │   └── db.ts              # pg pool + schema + load/save
│   ├── package.json
│   ├── tsconfig.json
│   ├── .env.example
│   └── Dockerfile
├── package.json               # Client (root), concurrent dev scripts
├── vite.config.ts
└── README.md

Why Redux if Yjs holds the data?

Yjs owns the kanban data. Redux owns app state that doesn't belong in the document:

  • UI ephemeral state (which card is being edited, composer open/closed)
  • Network status (online/offline from the browser)
  • Sync status (idle / syncing / error / last synced)

Redux Saga earns its keep on the orchestration side: subscribing to online/offline events, watching the y-websocket provider's status channel, and translating those into UI status the user sees in the topbar.

Known limitations

This is a prototype, not a product.

  • No drag-and-drop. Moving cards uses buttons. @dnd-kit would be a clean drop-in.
  • No authentication. Single board name (kanban-board-v1). To add auth, use Hocuspocus's onAuthenticate hook.
  • No reconnection backoff strategy beyond y-websocket's defaults.
  • Card descriptions are plain strings, not Y.Text. For true collaborative text editing, swap to Y.Text and bind with y-prosemirror or y-quill.
  • Render free tier has cold starts. Acceptable for a demo. For production, use a paid tier or a different host.

Further reading

About

Offline-first kanban with React + Yjs. Concurrent edits merge automatically — no last-write-wins, no sync queue, no conflict resolver.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages