Getting started
Installation
npm install can@6 --save
ES Module (CDN)
import { StacheElement } from "//unpkg.com/can@6/core.mjs";
Script tag
<script src="//unpkg.com/can@6/dist/global/core.js"></script>
Quick Example
import { Component } from "can";
Component.extend({
tag: "my-counter",
view: `
<span>{{this.count}}</span>
<button on:click="this.increment()">+1</button>
`,
ViewModel: {
count: { default: 0 },
increment() {
this.count++;
},
},
});
Observables (DefineMap)
Basic Properties
import { DefineMap } from "can";
const Todo = DefineMap.extend({
name: "string",
completed: { type: "boolean", default: false },
priority: { type: "number", default: 1 },
toggle() {
this.completed = !this.completed;
},
});
const todo = new Todo({ name: "Write docs" });
todo.completed = true;
Type coercion: "string", "number", "boolean", "any".
Computed Properties
const Person = DefineMap.extend({
first: "string",
last: "string",
get fullName() {
return this.first + " " + this.last;
},
set fullName(value) {
const parts = value.split(" ");
this.first = parts[0];
this.last = parts[1];
},
});
const p = new Person({ first: "John", last: "Doe" });
console.log(p.fullName); // "John Doe"
Default Values
const Config = DefineMap.extend({
timeout: { default: 5000 },
enabled: { default: true },
// Function default
items: {
default: () => [],
},
// Type default
user: {
type: User,
default: () => new User(),
},
});
Observable Lists (DefineList)
Basic Usage
import { DefineList } from "can";
const TodoList = DefineList.extend({
"#": Todo, // Item type
get completed() {
return this.filter((t) => t.completed);
},
get active() {
return this.filter((t) => !t.completed);
},
});
const todos = new TodoList([
{ name: "Learn CanJS", completed: true },
{ name: "Build app" },
]);
List Methods
// Add/remove
todos.push(new Todo({ name: "Test" }));
todos.unshift(todo);
todos.pop();
todos.shift();
todos.splice(0, 1, newTodo);
// Query
todos.filter((t) => t.completed);
todos.map((t) => t.name);
todos.forEach((t) => console.log(t));
// Properties
todos.length;
todos[0];
Components
Basic Component
import { Component } from "can";
Component.extend({
tag: "todo-item",
view: `
<input type="checkbox"
checked:bind="this.completed"
on:change="this.save()">
<span>{{this.name}}</span>
<button on:click="this.destroy()">×</button>
`,
ViewModel: {
name: "string",
completed: { type: "boolean", default: false },
save() {
/* ... */
},
destroy() {
/* ... */
},
},
});
Lifecycle Hooks
Component.extend({
tag: "my-component",
ViewModel: {
connectedCallback(element) {
console.log("Inserted into DOM");
// Cleanup on removal
return () => {
console.log("Removed from DOM");
};
},
},
});
Props from Attributes
Component.extend({
tag: "user-card",
view: `<h2>{{this.title}}</h2>`,
ViewModel: {
// Passed as <user-card user-id="5">
userId: "number",
// Default value
title: { type: "string", default: "User" },
},
});
Templates (can-stache)
Values & Expressions
<!-- Escaped -->
<h1>Hello {{this.name}}</h1>
<!-- Unescaped HTML -->
{{{this.htmlContent}}}
<!-- Expressions -->
<span>{{this.count * 2}}</span>
<span>{{this.first + " " + this.last}}</span>
<!-- Method calls -->
<button on:click="this.save()">Save</button>
Conditionals
<!-- if/else -->
{{# if(this.isLoggedIn) }}
<p>Welcome back!</p>
{{ else }}
<p>Please log in</p>
{{/ if }}
<!-- unless -->
{{# unless(this.hasErrors) }}
<button>Submit</button>
{{/ unless }}
<!-- Helpers -->
{{# eq(this.status, "active") }} Active {{/ eq }}
Loops
<!-- for..of -->
{{# for(todo of this.todos) }}
<li>{{scope.index}}: {{todo.name}}</li>
{{else}}
<li>No todos found</li>
{{/ for }}
<!-- each (legacy) -->
{{# each(this.items) }}
<div>{{this.name}}</div>
{{/ each }}
Loop scope: scope.index, scope.key, scope.first, scope.last.
Promises
{{# if(promise.isPending) }}
<div class="spinner">Loading...</div>
{{/ if }} {{# if(promise.isResolved) }}
<div>{{promise.value}}</div>
{{/ if }} {{# if(promise.isRejected) }}
<div class="error">Error: {{promise.reason}}</div>
{{/ if }}
Data Bindings
One-way Bindings
<!-- From parent to child -->
<my-component value:from="this.parentValue" />
<!-- From child to parent -->
<input value:to="this.name" />
<my-component result:to="this.output" />
<!-- Read-only -->
<span text:from="this.status">Default</span>
Two-way Bindings
<!-- Bi-directional sync -->
<input value:bind="this.name" />
<textarea value:bind="this.description" />
<input type="checkbox" checked:bind="this.enabled" />
<!-- Custom property -->
<my-slider position:bind="this.volume" />
⚠️ :bind syncs immediately on init.
Event Bindings
<!-- DOM events -->
<button on:click="this.save()">Save</button>
<input on:input="this.validate()" />
<form on:submit="this.handleSubmit()" />
<!-- Custom events -->
<my-component on:customEvent="this.handler()" />
<!-- Event + binding -->
<input on:input:value:to="this.searchTerm" />
<input on:change:value:to="this.filter" />
Routing (can-route)
Setup & Registration
import { route, ObservableObject } from "can";
route.data = new ObservableObject();
// Simple route
route.register("{page}", { page: "home" });
// Route with params
route.register("users/{id}", { page: "user" });
// Multi-segment
route.register("content/{type}/{id}");
// Start routing
route.start();
Navigation
<!-- Template links -->
<a href="{{routeUrl(page='settings')}}">Settings</a>
<a href="{{routeUrl(page='user', id=5)}}">User 5</a>
// Programmatic
route.data.page = "settings";
route.data.id = 5;
// Listen to changes
route.data.on("page", (ev, newVal) => {
console.log("Page changed to:", newVal);
});
Current Route Data
// Read current route
console.log(route.data.page);
console.log(route.data.id);
// In templates
{{# eq(route.data.page, "home") }}
<h1>Home Page</h1>
{{/ eq }}
REST Models (can-connect)
Setup
import { realtimeRestModel } from "can";
const Todo = realtimeRestModel("/api/todos/{id}").ObjectType;
// Custom configuration
const User = realtimeRestModel({
url: "/api/users/{id}",
ObjectType: DefineMap.extend({
name: "string",
email: "string",
}),
}).ObjectType;
CRUD Operations
// GET /api/todos?sort=name
Todo.getList({ sort: "name" }).then((todos) => console.log(todos));
// GET /api/todos/5
Todo.get({ id: 5 }).then((todo) => console.log(todo));
// POST /api/todos
const todo = new Todo({ name: "Write docs" });
todo.save().then(() => console.log("Saved"));
// PUT /api/todos/5
todo.name = "Updated";
todo.save();
// DELETE /api/todos/5
todo.destroy();
Real-time Updates
// Lists auto-update
const todosPromise = Todo.getList({});
// Creating updates all lists
new Todo({ name: "New" }).save();
// Destroying removes from lists
todo.destroy();
Real-time: Lists automatically reflect creates/updates/destroys.
Key Packages
| Package | Purpose |
|---|---|
can-stache |
Mustache-like templating engine |
can-stache-bindings |
Data and event bindings for templates |
can-define |
Observable properties (DefineMap/DefineList) |
can-component |
Custom element definitions |
can-route |
URL routing and history management |
can-connect |
Data layer and REST model connections |
can-stache-element |
StacheElement base class |
Gotchas
DefineMap Sealed in Strict Mode
DefineMap instances are sealed — setting undefined properties throws errors.
const todo = new Todo({ name: "Test" });
todo.unknownProp = "value"; // ❌ Error in strict mode
Define all properties in schema or use "*": "any" wildcard.
Two-way Binding Initialization
:bind initializes both parent and child immediately, even with event syntax.
<!-- Both sync on init -->
<input value:bind="this.name" />
Use :to for one-way if init sync is unwanted.
Route Encoding
Routes with / encode as %2F in URL:
route.register("image/{path}");
route.data.path = "foo/bar"; // URL: #!image%2Fbar
Use {path*} for glob matching instead.
Computed Property Caching
Computed getters are only cached when bound — unbound reads recalculate every time.
get expensiveComputed() {
return this.items.map(/* ... */); // Runs every access if unbound
}
Bind in template or component to enable caching.
Also see
- CanJS Documentation (canjs.com)
- Components Guide (canjs.com)
- Stache Templates (canjs.com)
- DefineMap API (canjs.com)
- Stache Bindings (canjs.com)
- Routing Documentation (canjs.com)