Cana with Vue 3 and Pinia
Build the categorized task system with Vue 3 and Pinia. Cana owns durable browser storage, while Pinia owns the reactive state that components render.
1. Start from zero
bun create vite cana-vue-pinia --template vue-ts
cd cana-vue-pinia
bun add @jumentix/cana @jumentix/cana-vue piniaRegister Pinia in main.ts, create src/cana.ts, then define task stores under
src/stores.
2. Simple implementation
The simple store action init() opens Cana, loads the current tables, and
returns the unsubscribe function from client.subscribe.
Components call store actions:
tasks.addTask(title, categoryId)tasks.toggleTask(task)
The store patches its arrays from CanaChangeEvent, so every component using the
store re-renders from committed storage state.
Vue 3 + Pinia: store patched from Cana
Pinia actions write to Cana and patch state from committed events.
### Vue 3 + Pinia: store patched from Cana
Pinia actions write to Cana and patch state from committed events.
#### package.json
```json
{
"name": "cana-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}
```
#### 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,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
```
#### index.html
```html
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
```
#### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()]
});
```
#### 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/stores/tasks.ts
```typescript
import { defineStore } from 'pinia';
import { applyCanaEventToRecords, connectCanaToPinia } from '@jumentix/cana-vue';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from '../cana';
export const useTasksStore = defineStore('tasks', {
state: () => ({
categories: [] as Category[],
tasks: [] as Task[],
events: [] as string[]
}),
getters: {
byCategory: (state) => (categoryId: string) =>
state.tasks.filter((task) => task.categoryId === categoryId)
},
actions: {
async init() {
await seedInitialData();
Object.assign(this, await loadAll());
const bridge = connectCanaToPinia({
client: cana,
apply: (event) => this.applyEvent(event)
});
return bridge.stop;
},
applyEvent(event: Parameters<typeof formatEvent>[0]) {
this.events = [...this.events, formatEvent(event)].slice(-8);
this.categories = applyCanaEventToRecords(this.categories, event, {
store: 'categories',
getKey: (category) => category.id,
sort: (a, b) => a.name.localeCompare(b.name)
});
this.tasks = applyCanaEventToRecords(this.tasks, event, {
store: 'tasks',
getKey: (task) => task.id,
sort: (a, b) => a.updatedAt - b.updatedAt
});
},
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()
});
}
}
});
```
#### src/App.vue
```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useTasksStore } from './stores/tasks';
const tasks = useTasksStore();
let stop = () => {};
onMounted(async () => {
stop = await tasks.init();
});
onUnmounted(() => stop());
</script>
<template>
<main class="app">
<h1>Cana + Vue 3 + Pinia</h1>
<div class="toolbar">
<button @click="tasks.addTask('New Pinia task', 'work')">Add task</button>
</div>
<section class="board">
<article v-for="category in tasks.categories" :key="category.id" class="category">
<h2>{{ category.name }}</h2>
<button
v-for="task in tasks.byCategory(category.id)"
:key="task.id"
class="task"
@click="tasks.toggleTask(task)"
>
{{ task.completed ? 'Done: ' : '' }}{{ task.title }}
</button>
</article>
</section>
<pre class="events">{{ tasks.events.join('\n') || 'No events yet.' }}</pre>
</main>
</template>
```
#### src/main.ts
```typescript
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles.css';
createApp(App).use(createPinia()).mount('#app');
```
#### 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-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}3. Advanced implementation
The advanced store keeps lastCursor in localStorage, subscribes with
sinceCursor, and reloads all tables when the replay cursor is no longer
available. It also writes category and first task in one Cana transaction.
Use onUnmounted(() => stop()) in components or teardown logic so old views do
not keep applying events after navigation.
Vue 3 + Pinia: replay-safe transaction flow
The Pinia store records the last Cana cursor and reloads when replay is unavailable.
### Vue 3 + Pinia: replay-safe transaction flow
The Pinia store records the last Cana cursor and reloads when replay is unavailable.
#### package.json
```json
{
"name": "cana-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}
```
#### 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,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
```
#### index.html
```html
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
```
#### vite.config.ts
```typescript
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()]
});
```
#### 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/stores/advancedTasks.ts
```typescript
import { defineStore } from 'pinia';
import { isCanaErrorCode } from '@jumentix/cana';
import { applyCanaEventToRecords, connectCanaToPinia } from '@jumentix/cana-vue';
import { cana, loadAll, seedInitialData, formatEvent, type Category, type Task } from '../cana';
let lastCursor = Number(localStorage.getItem('pinia:lastCursor') ?? 0);
export const useTasksStore = defineStore('advancedTasks', {
state: () => ({
categories: [] as Category[],
tasks: [] as Task[],
events: [] as string[],
canaError: null as unknown
}),
getters: {
byCategory: (state) => (categoryId: string) =>
state.tasks.filter((task) => task.categoryId === categoryId)
},
actions: {
async init() {
await seedInitialData();
Object.assign(this, await loadAll());
try {
const bridge = connectCanaToPinia({
client: cana,
sinceCursor: lastCursor,
apply: (event) => this.applyEvent(event)
});
return bridge.stop;
} catch (error) {
if (isCanaErrorCode(error, 'NotFound')) {
Object.assign(this, await loadAll());
const bridge = connectCanaToPinia({
client: cana,
apply: (event) => this.applyEvent(event)
});
return bridge.stop;
}
this.canaError = error;
throw error;
}
},
applyEvent(event: Parameters<typeof formatEvent>[0]) {
lastCursor = event.cursor;
localStorage.setItem('pinia:lastCursor', String(lastCursor));
this.events = [...this.events, formatEvent(event)].slice(-8);
this.categories = applyCanaEventToRecords(this.categories, event, {
store: 'categories',
getKey: (category) => category.id,
sort: (a, b) => a.name.localeCompare(b.name)
});
this.tasks = applyCanaEventToRecords(this.tasks, event, {
store: 'tasks',
getKey: (task) => task.id,
sort: (a, b) => a.updatedAt - b.updatedAt
});
},
async createCategoryWithFirstTask(name: string, title: string) {
const now = Date.now();
const categoryId = name.toLowerCase().replace(/\s+/g, '-');
await cana.transaction('readwrite', ['categories', 'tasks'], async (scope) => {
await scope.table<Category>('categories').put({
id: categoryId,
name,
color: '#dc2626',
createdAt: now,
updatedAt: now
});
await scope.table<Task>('tasks').put({
id: crypto.randomUUID(),
title,
categoryId,
completed: false,
priority: 'high',
createdAt: now,
updatedAt: now
});
});
},
async toggleTask(task: Task) {
await cana.table<Task>('tasks').update(task.id, {
completed: !task.completed,
updatedAt: Date.now()
});
}
}
});
```
#### src/App.vue
```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useTasksStore } from './stores/advancedTasks';
const tasks = useTasksStore();
let stop = () => {};
onMounted(async () => {
stop = await tasks.init();
});
onUnmounted(() => stop());
</script>
<template>
<main class="app">
<h1>Cana + Vue 3 + Pinia advanced</h1>
<div class="toolbar">
<button @click="tasks.createCategoryWithFirstTask('QA', 'Created in one transaction')">
Create category + task
</button>
</div>
<section class="board">
<article v-for="category in tasks.categories" :key="category.id" class="category">
<h2>{{ category.name }}</h2>
<button
v-for="task in tasks.byCategory(category.id)"
:key="task.id"
class="task"
@click="tasks.toggleTask(task)"
>
{{ task.completed ? 'Done: ' : '' }}{{ task.title }}
</button>
</article>
</section>
<pre class="events">{{ tasks.events.join('\n') || 'No events yet.' }}</pre>
</main>
</template>
```
#### src/main.ts
```typescript
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles.css';
createApp(App).use(createPinia()).mount('#app');
```
#### 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-vue-pinia-tasks",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@jumentix/cana": "^0.1.0",
"@jumentix/cana-vue": "^0.1.0",
"@vitejs/plugin-vue": "^6.0.5",
"pinia": "^4.0.3",
"typescript": "^6.0.3",
"vite": "^7.2.7",
"vue": "^3.5.41",
"vue-tsc": "^3.1.8"
}
}4. Final app shape
src/
cana.ts
main.ts
App.vue
stores/
tasks.ts
advancedTasks.tsPinia getters should hold derived views such as tasks by category, not duplicated copies of Cana data.
Download the complete Vite app used by the advanced example: cana-vue-pinia.zip.
5. Checklist
-
createPinia()is installed before components use stores. -
init()opens Cana, loads tables, then subscribes. -
onUnmountedunsubscribes listeners. - Store actions write to Cana and state patches from committed events.
- Advanced flows reload from tables when replay is unavailable.
Next
Compare the same model in React Context and React Redux.