Cana with React Context API
Build a categorized task system from an empty React app to a working offline implementation. The app stores categories and tasks in Cana, listens to committed Cana events, and updates React state through a Context provider.
1. Start from zero
bun create vite cana-react-context --template react-ts
cd cana-react-context
bun add @jumentix/cana @jumentix/cana-reactUse two stores:
categories: task buckets withid,name,color, timestamps.tasks: records withcategoryId,completed,priority, timestamps.
Indexes make the UI cheap to reload:
categories.byNametasks.byCategorytasks.byCompletedtasks.byUpdatedAt
2. Simple implementation
Create src/cana.ts, then wrap the app with TasksProvider. The provider opens
Cana once, loads the current tables, subscribes to CanaChangeEvent, and uses a
reducer to patch component state when writes commit.
The important part is this flow:
- UI calls
cana.table('tasks').add(...). - Cana commits the write.
client.subscribe((event) => ...)receives the committed event.- The reducer maps
created | updated | deleted | clearedto React state. - Components re-render from Context state.
React Context: Category and Task tables
A provider listens to committed Cana events and updates reducer state.
### React Context: Category and Task tables
A provider listens to committed Cana events and updates reducer state.
#### package.json
```json
{
"name": "cana-react-context-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-react": "^0.1.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4"
}
}
```
#### tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"],
"jsx": "react-jsx"
},
"include": ["src"]
}
```
#### index.html
```html
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
```
#### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()]
});
```
#### src/vite-env.d.ts
```typescript
/// <reference types="vite/client" />
```
#### src/cana.ts
```typescript
import { createClient, type CanaChangeEvent, type CanaSchema } from '@jumentix/cana';
export type Category = {
id: string;
name: string;
color: string;
createdAt: number;
updatedAt: number;
};
export type Task = {
id: string;
title: string;
categoryId: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
notes?: string;
createdAt: number;
updatedAt: number;
};
export const taskSchema: CanaSchema = {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name', unique: true }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byCompleted', keyPath: 'completed' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
};
export const cana = createClient({
name: 'tutorial-cana-tasks',
schema: taskSchema,
originId: 'tasks-ui',
retainedEvents: 100
});
export async function openTaskDatabase() {
await cana.open();
return cana;
}
export async function loadAll() {
const [categories, tasks] = await Promise.all([
cana.table<Category>('categories').query({ index: 'byName' }),
cana.table<Task>('tasks').query({ index: 'byUpdatedAt' })
]);
return { categories: [...categories], tasks: [...tasks] };
}
export async function seedInitialData() {
await openTaskDatabase();
if (await cana.table<Category>('categories').count()) return;
const now = Date.now();
await cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await scope.table<Task>('tasks').bulkAdd([
{
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
},
{
id: 'task-2',
title: 'Review category filters',
categoryId: 'home',
completed: true,
priority: 'medium',
createdAt: now,
updatedAt: now
}
]);
});
}
export function formatEvent(event: CanaChangeEvent) {
return event.cursor + ': ' + event.type + ' ' + event.store + '/' + String(event.key ?? 'all');
}
```
#### src/TasksProvider.tsx
```typescript
import React, { createContext, useContext, useEffect, useReducer, useState } from 'react';
import { applyCanaEventToRecords, useCanaSubscription } from '@jumentix/cana-react';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from './cana';
type State = { categories: Category[]; tasks: Task[]; events: string[] };
type Action =
| { type: 'loaded'; payload: Omit<State, 'events'> }
| { type: 'event'; event: Parameters<typeof formatEvent>[0] };
function reducer(state: State, action: Action): State {
if (action.type === 'loaded') return { ...action.payload, events: [] };
const event = action.event;
return {
categories: applyCanaEventToRecords(state.categories, event, {
store: 'categories',
getKey: (category) => category.id,
sort: (a, b) => a.name.localeCompare(b.name)
}),
tasks: applyCanaEventToRecords(state.tasks, event, {
store: 'tasks',
getKey: (task) => task.id,
sort: (a, b) => a.updatedAt - b.updatedAt
}),
events: [...state.events, formatEvent(event)].slice(-8)
};
}
const TasksContext = createContext<{
state: State;
addTask(title: string, categoryId: string): Promise<void>;
toggleTask(task: Task): Promise<void>;
} | null>(null);
export function TasksProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [state, dispatch] = useReducer(reducer, { categories: [], tasks: [], events: [] });
useEffect(() => {
let alive = true;
void seedInitialData()
.then(loadAll)
.then((payload) => {
if (!alive) return;
dispatch({ type: 'loaded', payload });
setReady(true);
});
return () => { alive = false; };
}, []);
useCanaSubscription(ready ? cana : null, (event) => {
dispatch({ type: 'event', event });
});
const api = {
state,
async addTask(title: string, categoryId: string) {
const now = Date.now();
await cana.table<Task>('tasks').add({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
},
async toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
};
return <TasksContext.Provider value={api}>{children}</TasksContext.Provider>;
}
export function useTasks() {
const ctx = useContext(TasksContext);
if (!ctx) throw new Error('useTasks must run inside TasksProvider');
return ctx;
}
```
#### src/App.tsx
```typescript
import { TasksProvider, useTasks } from './TasksProvider';
function TaskBoardExample() {
const { state, addTask, toggleTask } = useTasks();
const groups = state.categories.map((category) => ({
category,
tasks: state.tasks.filter((task) => task.categoryId === category.id)
}));
return (
<main className="app">
<h1>Cana + React Context</h1>
<div className="toolbar">
<button onClick={() => void addTask('New work task', 'work')}>
Add task
</button>
</div>
<section className="board">
{groups.map(({ category, tasks }) => (
<article className="category" key={category.id}>
<h2>{category.name}</h2>
{tasks.map((task) => (
<button className="task" key={task.id} onClick={() => void toggleTask(task)}>
{task.completed ? 'Done: ' : ''}{task.title}
</button>
))}
</article>
))}
</section>
<pre className="events">{state.events.join('\n') || 'No events yet.'}</pre>
</main>
);
}
export default function App() {
return (
<TasksProvider>
<TaskBoardExample />
</TasksProvider>
);
}
```
#### src/main.tsx
```typescript
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
```
#### src/styles.css
```css
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #f8fafc;
color: #0f172a;
}
button {
cursor: pointer;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 32px;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.category {
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 16px;
background: white;
}
.task {
display: block;
width: 100%;
margin-top: 8px;
border: 1px solid #dbe3ef;
border-radius: 6px;
padding: 8px;
text-align: left;
background: #f8fafc;
}
.events {
margin-top: 16px;
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 12px;
background: #0f172a;
color: #dbeafe;
white-space: pre-wrap;
}
```{
"name": "cana-react-context-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-react": "^0.1.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4"
}
}3. Advanced implementation
The advanced version adds the parts you need in a serious offline UI:
- one
readwritetransaction creates a category and its first task together; sinceCursorresumes a listener after a reload;isCanaErrorCode(error, 'NotFound')detects an expired replay window;- a full table reload repairs state before resubscribing;
originIdgives you a safe place to ignore your own echo if you also apply optimistic UI patches.
Inside a transaction, keep the body limited to Cana/IndexedDB work. Do not put
fetch, timers, or unrelated async work inside the transaction callback.
React Context: replay and transaction flow
The app keeps the same tables and adds replay recovery plus one multi-store transaction.
### React Context: replay and transaction flow
The app keeps the same tables and adds replay recovery plus one multi-store transaction.
#### package.json
```json
{
"name": "cana-react-context-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-react": "^0.1.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4"
}
}
```
#### tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"],
"jsx": "react-jsx"
},
"include": ["src"]
}
```
#### index.html
```html
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
```
#### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()]
});
```
#### src/vite-env.d.ts
```typescript
/// <reference types="vite/client" />
```
#### src/cana.ts
```typescript
import { createClient, type CanaChangeEvent, type CanaSchema } from '@jumentix/cana';
export type Category = {
id: string;
name: string;
color: string;
createdAt: number;
updatedAt: number;
};
export type Task = {
id: string;
title: string;
categoryId: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
notes?: string;
createdAt: number;
updatedAt: number;
};
export const taskSchema: CanaSchema = {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name', unique: true }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byCompleted', keyPath: 'completed' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
};
export const cana = createClient({
name: 'tutorial-cana-tasks',
schema: taskSchema,
originId: 'tasks-ui',
retainedEvents: 100
});
export async function openTaskDatabase() {
await cana.open();
return cana;
}
export async function loadAll() {
const [categories, tasks] = await Promise.all([
cana.table<Category>('categories').query({ index: 'byName' }),
cana.table<Task>('tasks').query({ index: 'byUpdatedAt' })
]);
return { categories: [...categories], tasks: [...tasks] };
}
export async function seedInitialData() {
await openTaskDatabase();
if (await cana.table<Category>('categories').count()) return;
const now = Date.now();
await cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').bulkAdd([
{ id: 'work', name: 'Work', color: '#2563eb', createdAt: now, updatedAt: now },
{ id: 'home', name: 'Home', color: '#16a34a', createdAt: now, updatedAt: now }
]);
await scope.table<Task>('tasks').bulkAdd([
{
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
},
{
id: 'task-2',
title: 'Review category filters',
categoryId: 'home',
completed: true,
priority: 'medium',
createdAt: now,
updatedAt: now
}
]);
});
}
export function formatEvent(event: CanaChangeEvent) {
return event.cursor + ': ' + event.type + ' ' + event.store + '/' + String(event.key ?? 'all');
}
```
#### src/TasksProvider.tsx
```typescript
import React, { createContext, useContext, useEffect, useReducer, useState } from 'react';
import { applyCanaEventToRecords } from '@jumentix/cana-react';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from './cana';
import { subscribeWithReplay } from './advancedCana';
type State = { categories: Category[]; tasks: Task[]; events: string[] };
type Action =
| { type: 'loaded'; payload: Omit<State, 'events'> }
| { type: 'event'; event: Parameters<typeof formatEvent>[0] };
function reducer(state: State, action: Action): State {
if (action.type === 'loaded') return { ...action.payload, events: state.events };
const event = action.event;
return {
categories: applyCanaEventToRecords(state.categories, event, {
store: 'categories',
getKey: (category) => category.id,
sort: (a, b) => a.name.localeCompare(b.name)
}),
tasks: applyCanaEventToRecords(state.tasks, event, {
store: 'tasks',
getKey: (task) => task.id,
sort: (a, b) => a.updatedAt - b.updatedAt
}),
events: [...state.events, formatEvent(event)].slice(-8)
};
}
const TasksContext = createContext<{
state: State;
addTask(title: string, categoryId: string): Promise<void>;
toggleTask(task: Task): Promise<void>;
} | null>(null);
export function TasksProvider({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const [state, dispatch] = useReducer(reducer, { categories: [], tasks: [], events: [] });
async function reloadState() {
dispatch({ type: 'loaded', payload: await loadAll() });
}
useEffect(() => {
let alive = true;
void seedInitialData()
.then(loadAll)
.then((payload) => {
if (!alive) return;
dispatch({ type: 'loaded', payload });
setReady(true);
});
return () => { alive = false; };
}, []);
useEffect(() => {
if (!ready) return undefined;
let stop: (() => void) | undefined;
let active = true;
void subscribeWithReplay(
(event) => dispatch({ type: 'event', event }),
reloadState
).then((cleanup) => {
if (active) stop = cleanup;
else cleanup();
});
return () => {
active = false;
stop?.();
};
}, [ready]);
const api = {
state,
async addTask(title: string, categoryId: string) {
const now = Date.now();
await cana.table<Task>('tasks').add({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'medium',
createdAt: now,
updatedAt: now
});
},
async toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
};
return <TasksContext.Provider value={api}>{children}</TasksContext.Provider>;
}
export function useTasks() {
const ctx = useContext(TasksContext);
if (!ctx) throw new Error('useTasks must run inside TasksProvider');
return ctx;
}
```
#### src/advancedCana.ts
```typescript
import { isCanaErrorCode, type CanaChangeEvent } from '@jumentix/cana';
import { cana, loadAll, type Category, type Task } from './cana';
let lastCursor = Number(localStorage.getItem('tasks:lastCursor') ?? 0);
export async function subscribeWithReplay(
apply: (event: CanaChangeEvent) => void,
reload: () => Promise<void>
) {
const rememberAndApply = (event: CanaChangeEvent) => {
apply(event);
lastCursor = event.cursor;
localStorage.setItem('tasks:lastCursor', String(lastCursor));
};
try {
return cana.subscribe(rememberAndApply, { sinceCursor: lastCursor });
} catch (error) {
if (isCanaErrorCode(error, 'NotFound')) {
lastCursor = 0;
localStorage.setItem('tasks:lastCursor', '0');
await reload();
return cana.subscribe(rememberAndApply);
}
throw error;
}
}
export async function createCategoryWithFirstTask(name: string, title: string) {
const now = Date.now();
const categoryId = name.toLowerCase().replace(/\s+/g, '-');
return cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').put({
id: categoryId,
name,
color: '#f97316',
createdAt: now,
updatedAt: now
});
await scope.table<Task>('tasks').put({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
});
}
```
#### src/App.tsx
```typescript
import { createCategoryWithFirstTask } from './advancedCana';
import { TasksProvider, useTasks } from './TasksProvider';
function AdvancedTaskBoard() {
const { state, addTask, toggleTask } = useTasks();
const groups = state.categories.map((category) => ({
category,
tasks: state.tasks.filter((task) => task.categoryId === category.id)
}));
return (
<main className="app">
<h1>Cana + React Context advanced</h1>
<div className="toolbar">
<button onClick={() => void addTask('Standalone task', 'work')}>Add task</button>
<button onClick={() => void createCategoryWithFirstTask('Operations', 'Created in the same transaction')}>
Create category + task
</button>
</div>
<section className="board">
{groups.map(({ category, tasks }) => (
<article className="category" key={category.id}>
<h2>{category.name}</h2>
{tasks.map((task) => (
<button className="task" key={task.id} onClick={() => void toggleTask(task)}>
{task.completed ? 'Done: ' : ''}{task.title}
</button>
))}
</article>
))}
</section>
<pre className="events">{state.events.join('\n') || 'No events yet.'}</pre>
</main>
);
}
export default function App() {
return (
<TasksProvider>
<AdvancedTaskBoard />
</TasksProvider>
);
}
```
#### src/main.tsx
```typescript
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
```
#### src/styles.css
```css
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #f8fafc;
color: #0f172a;
}
button {
cursor: pointer;
}
.app {
max-width: 960px;
margin: 0 auto;
padding: 32px;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
}
.category {
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 16px;
background: white;
}
.task {
display: block;
width: 100%;
margin-top: 8px;
border: 1px solid #dbe3ef;
border-radius: 6px;
padding: 8px;
text-align: left;
background: #f8fafc;
}
.events {
margin-top: 16px;
border: 1px solid #dbe3ef;
border-radius: 8px;
padding: 12px;
background: #0f172a;
color: #dbeafe;
white-space: pre-wrap;
}
```{
"name": "cana-react-context-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-react": "^0.1.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4"
}
}4. Final app shape
The finished Context implementation has this shape:
src/
cana.ts
TasksProvider.tsx
advancedCana.ts
App.tsxTasksProvider is the only component that knows how Cana events become React
state. Leaf components stay boring: they call addTask and
toggleTask, then render state.categories, state.tasks, and
state.events.
Download the complete Vite app used by the advanced example: cana-react-context.zip.
5. Checklist
-
open()runs before any table call. - The provider unsubscribes in the
useEffectcleanup. - Components update from committed Cana events.
- Advanced flows use
transaction()for multi-store writes. - Replay failure reloads from tables before resubscribing.
Next
Compare this with React Redux for larger apps with explicit slices and selectors, or Vue 3 + Pinia for the Vue store pattern.