Cana com React Redux
Construa o mesmo sistema de tarefas categorizadas com Redux Toolkit. O Cana continua sendo a fonte offline durável, e o Redux vira o cache de renderização que recebe eventos confirmados do Cana.
1. Comece do zero
bun create vite cana-react-redux --template react-ts
cd cana-react-redux
bun add @jumentix/cana @jumentix/cana-react @reduxjs/toolkit react-reduxO schema é o mesmo do tutorial com Context: categories e tasks, com indexes
por nome, categoria, estado concluído e atualização.
2. Implementação simples
Crie slices para categories, tasks e events. As actions de escrita ainda
gravam no Cana. O listener é quem mantém o Redux atualizado:
const stop = client.subscribe((event) => {
store.dispatch(applyCanaEvent(event));
});Assim a regra fica simples: Redux renderiza o que o Cana confirmou. Cliques em botões não mudam a store diretamente, a menos que você adicione UI otimista de propósito.
React Redux: store atualizada por eventos Cana
Redux renderiza o cache; Cana continua sendo a fonte duravel.
### React Redux: store atualizada por eventos Cana
Redux renderiza o cache; Cana continua sendo a fonte duravel.
#### 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. Implementação avançada
A versão avançada com Redux adiciona:
- um thunk que grava categoria e primeira tarefa em uma transação Cana;
- um listener que dispara
applyCanaEvent; - um
lastCursorarmazenado; - replay com
subscribe(..., { sinceCursor }); - caminho de reload quando o Cana retorna
NotFoundpara cursor antigo; - um slice de erro que guarda dados planos de
CanaError.
Esse padrão escala bem porque reducers continuam determinísticos enquanto thunks controlam efeitos colaterais.
React Redux: thunk transacional com replay
Um thunk grava Category e Task juntas enquanto o listener retoma do ultimo cursor.
### React Redux: thunk transacional com replay
Um thunk grava Category e Task juntas enquanto o listener retoma do ultimo 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. Formato final do app
src/
cana.ts
store.ts
App.tsxUse selectors para visões derivadas:
- tarefas por categoria;
- tarefas pendentes;
- alterações recentes vindas de
events.
Baixe o app Vite completo usado pelo exemplo avançado: cana-react-redux.zip.
5. Checklist
- Inicialização da store carrega as tabelas Cana uma vez.
-
client.subscribedispara updates por registro. - Thunks escrevem no Cana, reducers atualizam por eventos confirmados.
- Resultados de transação são verificados antes de mostrar sucesso.
- Lacunas de replay recarregam as tabelas canônicas.
Próximo
Use React Context quando a superfície de estado for pequena. Use Vue 3 + Pinia para construir o mesmo padrão offline em Vue.