Cana with React Redux
Build the same categorized task system with Redux Toolkit. Cana remains the durable offline source, and Redux becomes the render cache that receives committed Cana events.
1. Start from zero
bun create vite cana-react-redux --template react-ts
cd cana-react-redux
bun add @jumentix/cana @jumentix/cana-react @reduxjs/toolkit react-reduxThe schema is the same as the Context tutorial: categories and tasks, with
indexes for name, category, completed state, and updated time.
2. Simple implementation
Create slices for categories, tasks, and events. The write actions still
write to Cana. The listener is what keeps Redux current:
const stop = client.subscribe((event) => {
store.dispatch(applyCanaEvent(event));
});This keeps the rule simple: Redux renders what Cana has committed. Button clicks do not mutate the store directly unless you intentionally add optimistic UI.
React Redux: store updated by Cana events
Redux renders the cache; Cana remains the durable source of truth.
### React Redux: store updated by Cana events
Redux renders the cache; Cana remains the durable source of truth.
#### package.json
```json
{
"name": "cana-react-redux-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",
"@reduxjs/toolkit": "^2.12.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-redux": "^9.3.0"
},
"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/store.ts
```typescript
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { CanaChangeEvent } from '@jumentix/cana';
import { connectCanaToRedux } from '@jumentix/cana-react/redux';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from './cana';
type State = { categories: Category[]; tasks: Task[]; events: string[] };
function upsert<T extends { id: string }>(records: T[], record: T) {
const next = records.filter((item) => item.id !== record.id).concat(record);
return next;
}
const slice = createSlice({
name: 'tasks',
initialState: { categories: [], tasks: [], events: [] } as State,
reducers: {
replaceAll(_state, action: PayloadAction<{ categories: Category[]; tasks: Task[] }>) {
return { categories: action.payload.categories, tasks: action.payload.tasks, events: [] };
},
applyCanaEvent(state, action: PayloadAction<CanaChangeEvent>) {
const event = action.payload;
state.events = [...state.events, formatEvent(event)].slice(-8);
if (event.store === 'categories' && event.record) {
state.categories = upsert(state.categories, event.record as Category)
.sort((a, b) => a.name.localeCompare(b.name));
}
if (event.store === 'tasks' && event.record) {
state.tasks = upsert(state.tasks, event.record as Task)
.sort((a, b) => a.updatedAt - b.updatedAt);
}
}
}
});
export const { applyCanaEvent, replaceAll } = slice.actions;
export const store = configureStore({ reducer: slice.reducer });
export type RootState = ReturnType<typeof store.getState>;
export async function startCanaRedux() {
await seedInitialData();
store.dispatch(replaceAll(await loadAll()));
const bridge = connectCanaToRedux({
client: cana,
dispatch: store.dispatch,
mapEvent: (event) => applyCanaEvent(event)
});
return bridge.stop;
}
export async function 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
});
}
export async function toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
```
#### src/App.tsx
```typescript
import { useEffect } from 'react';
import { Provider, useSelector } from 'react-redux';
import { addTask, toggleTask, startCanaRedux, store, type RootState } from './store';
function ReduxTaskBoard() {
const { categories, tasks, events } = useSelector((state: RootState) => state);
useEffect(() => {
let stop = () => {};
void startCanaRedux().then((cleanup) => { stop = cleanup; });
return () => stop();
}, []);
return (
<main className="app">
<h1>Cana + React Redux</h1>
<div className="toolbar">
<button onClick={() => void addTask('New Redux task', 'work')}>Add task</button>
</div>
<section className="board">
{categories.map((category) => (
<article className="category" key={category.id}>
<h2>{category.name}</h2>
{tasks.filter((task) => task.categoryId === category.id).map((task) => (
<button className="task" key={task.id} onClick={() => void toggleTask(task)}>
{task.completed ? 'Done: ' : ''}{task.title}
</button>
))}
</article>
))}
</section>
<pre className="events">{events.join('\n') || 'No events yet.'}</pre>
</main>
);
}
export default function App() {
return (
<Provider store={store}>
<ReduxTaskBoard />
</Provider>
);
}
```
#### 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-redux-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",
"@reduxjs/toolkit": "^2.12.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-redux": "^9.3.0"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4"
}
}3. Advanced implementation
The advanced Redux version adds:
- a thunk that writes category and first task in one Cana transaction;
- a listener that dispatches
applyCanaEvent; - a stored
lastCursor; - a replay path using
subscribe(..., { sinceCursor }); - a reload path when Cana reports
NotFoundfor an old cursor; - an error slice that stores plain
CanaErrordata.
This pattern scales well because reducers stay deterministic while thunks own side effects.
React Redux: replay-safe transaction thunk
A Redux thunk writes Category and Task together while the listener resumes from the last cursor.
### React Redux: replay-safe transaction thunk
A Redux thunk writes Category and Task together while the listener resumes from the last cursor.
#### package.json
```json
{
"name": "cana-react-redux-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",
"@reduxjs/toolkit": "^2.12.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-redux": "^9.3.0"
},
"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/store.ts
```typescript
import { configureStore, createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { isCanaErrorCode, type CanaChangeEvent } from '@jumentix/cana';
import { connectCanaToRedux } from '@jumentix/cana-react/redux';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from './cana';
type State = { categories: Category[]; tasks: Task[]; events: string[]; canaError: unknown };
let lastCursor = Number(localStorage.getItem('redux:lastCursor') ?? 0);
function upsert<T extends { id: string }>(records: T[], record: T) {
return records.filter((item) => item.id !== record.id).concat(record);
}
const slice = createSlice({
name: 'tasks',
initialState: { categories: [], tasks: [], events: [], canaError: null } as State,
reducers: {
replaceAll(state, action: PayloadAction<{ categories: Category[]; tasks: Task[] }>) {
state.categories = action.payload.categories;
state.tasks = action.payload.tasks;
},
setCanaError(state, action: PayloadAction<unknown>) {
state.canaError = action.payload;
},
applyCanaEvent(state, action: PayloadAction<CanaChangeEvent>) {
const event = action.payload;
lastCursor = event.cursor;
localStorage.setItem('redux:lastCursor', String(lastCursor));
state.events = [...state.events, formatEvent(event)].slice(-8);
if (event.store === 'categories' && event.record) {
state.categories = upsert(state.categories, event.record as Category)
.sort((a, b) => a.name.localeCompare(b.name));
}
if (event.store === 'tasks' && event.record) {
state.tasks = upsert(state.tasks, event.record as Task)
.sort((a, b) => a.updatedAt - b.updatedAt);
}
}
}
});
export const { applyCanaEvent, replaceAll, setCanaError } = slice.actions;
export const store = configureStore({ reducer: slice.reducer });
export type RootState = ReturnType<typeof store.getState>;
export async function startCanaReduxWithReplay() {
await seedInitialData();
store.dispatch(replaceAll(await loadAll()));
try {
const bridge = connectCanaToRedux({
client: cana,
dispatch: store.dispatch,
sinceCursor: lastCursor,
mapEvent: (event) => applyCanaEvent(event)
});
return bridge.stop;
} catch (error) {
if (isCanaErrorCode(error, 'NotFound')) {
store.dispatch(replaceAll(await loadAll()));
const bridge = connectCanaToRedux({
client: cana,
dispatch: store.dispatch,
mapEvent: (event) => applyCanaEvent(event)
});
return bridge.stop;
}
store.dispatch(setCanaError(error));
throw error;
}
}
export const createCategoryWithFirstTask = createAsyncThunk(
'tasks/createCategoryWithFirstTask',
async ({ name, title }: { 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: '#7c3aed',
createdAt: now,
updatedAt: now
});
await scope.table<Task>('tasks').put({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
});
}
);
export async function 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
});
}
export async function toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
```
#### src/App.tsx
```typescript
import { useEffect } from 'react';
import { Provider, useDispatch, useSelector } from 'react-redux';
import {
addTask,
toggleTask,
createCategoryWithFirstTask,
startCanaReduxWithReplay,
store,
type RootState
} from './store';
function AdvancedReduxBoard() {
const dispatch = useDispatch<typeof store.dispatch>();
const { categories, tasks, events } = useSelector((state: RootState) => state);
useEffect(() => {
let stop = () => {};
void startCanaReduxWithReplay().then((cleanup) => { stop = cleanup; });
return () => stop();
}, []);
return (
<main className="app">
<h1>Cana + React Redux advanced</h1>
<div className="toolbar">
<button onClick={() => void addTask('New Redux task', 'work')}>Add task</button>
<button onClick={() => void dispatch(createCategoryWithFirstTask({
name: 'Release',
title: 'Created in one transaction'
}))}>
Create category + task
</button>
</div>
<section className="board">
{categories.map((category) => (
<article className="category" key={category.id}>
<h2>{category.name}</h2>
{tasks.filter((task) => task.categoryId === category.id).map((task) => (
<button className="task" key={task.id} onClick={() => void toggleTask(task)}>
{task.completed ? 'Done: ' : ''}{task.title}
</button>
))}
</article>
))}
</section>
<pre className="events">{events.join('\n') || 'No events yet.'}</pre>
</main>
);
}
export default function App() {
return (
<Provider store={store}>
<AdvancedReduxBoard />
</Provider>
);
}
```
#### 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-redux-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",
"@reduxjs/toolkit": "^2.12.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-redux": "^9.3.0"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4"
}
}4. Final app shape
src/
cana.ts
store.ts
App.tsxUse selectors for derived views:
- tasks by category;
- incomplete tasks;
- recent changes from
events.
Download the complete Vite app used by the advanced example: cana-react-redux.zip.
5. Checklist
- Store initialization loads Cana tables once.
-
client.subscribedispatches record-level updates. - Thunks write to Cana, reducers update from committed events.
- Transaction results are checked before showing success.
- Replay gaps reload the canonical tables.
Next
Use React Context when the state surface is small. Use Vue 3 + Pinia when building the same offline pattern in Vue.