NexusCS

CanJS

JavaScript
CanJS: MVVM framework with observables, stache templates, custom elements, and real-time data connections.
javascript
framework
mvvm
observables

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