Skip to content

conversejs/skeletor

Repository files navigation

Skeletor

XMPP Chat CI Tests

Skeletor is a lightweight, TypeScript-first reactive data library.

It lets you define typed models and react to changes with events or subscriptions, while persisting to IndexedDB, localStorage, sessionStorage, SQLite (Node) or a REST API.

Skeletor is a modernized rewrite of Backbone's Models and Collections, without jQuery, without Underscore, and without Views or Routing. If you know Backbone, everything transfers directly. If you don't, there's nothing to unlearn.

Skeletor powers Converse.js, a full-featured open-source XMPP chat client.

Why Skeletor?

  • Reactive models — set an attribute, get a change event. Subscribe with a callback and an unsubscribe function. Works with any UI layer.
  • Direct attribute accessmodel.attrs.name = 'Bob' fires change events, no boilerplate needed.
  • Computed properties — declare derived values with explicit dependencies; they cache, recalculate, and fire change events automatically.
  • Store-style subscriptionssubscribe() returns an unsubscribe function, compatible with React's useSyncExternalStore and similar APIs.
  • Built-in persistence — IndexedDB, localStorage, sessionStorage, SQLite (Node), and REST out of the box.
  • TypeScript-first — full type definitions, generic model attributes, typed computed properties.
  • No jQuery, no Underscore — native browser APIs and lodash-es with individual imports for tree-shaking.
  • Works anywhere — browser, Node.js (22+), Web Workers. ESM + CJS builds included.
  • Backbone-compatibleget, set, events, collections: all there. Drop-in replacement for Backbone's data layer.

Installation

npm install @converse/skeletor

Quick Start

Model

import { Model } from '@converse/skeletor';

interface UserAttrs {
  firstName: string;
  lastName: string;
  active: boolean;
}

class User extends Model<UserAttrs> {
  get defaults() {
    return { firstName: '', lastName: '', active: false };
  }

  get computed() {
    return {
      fullName: {
        deps: ['firstName', 'lastName'],
        fn: (model: User) => `${model.get('firstName')} ${model.get('lastName')}`,
      },
    };
  }
}

const user = new User({ firstName: 'Alice', lastName: 'Smith' });

// Read — three equivalent ways
user.get('firstName');     // → 'Alice'
user.attrs.firstName;      // → 'Alice'
user.get('fullName');      // → 'Alice Smith'  (computed — cached, never persisted)

// Write — fires change events
user.attrs.firstName = 'Bob';   // triggers 'change:firstName' and 'change:fullName'
user.set('active', true);       // triggers 'change:active'

// React to changes
user.on('change:fullName', (model, value) => {
  console.log('Name is now', value);
});

// Store-style subscription (returns unsubscribe function)
const unsub = user.subscribe((model, changed) => {
  console.log('Changed attrs:', changed);
});
unsub(); // clean up

Collection

import { Model, Collection } from '@converse/skeletor';

class User extends Model {}

class Users extends Collection {
  get model() { return User; }
  get url() { return '/api/users'; }
}

const users = new Users([
  { id: 1, name: 'Alice', active: true },
  { id: 2, name: 'Bob',   active: false },
]);

users.add({ id: 3, name: 'Carol', active: true });

const active = users.filter(u => u.get('active'));
const names  = users.pluck('name'); // → ['Alice', 'Bob', 'Carol']

// Subscribe to structural changes (fires once per operation, not per model)
const unsub = users.subscribe((collection) => {
  console.log('collection changed, length:', collection.length);
});

// Load from the server
await users.fetch();

Browser Storage

Persist models locally without a server — no extra packages needed.

import { Model, BrowserStorage } from '@converse/skeletor';

class Settings extends Model {
  constructor(...args) {
    super(...args);
    this.browserStorage = new BrowserStorage('app-settings', 'local');
  }
}

const settings = new Settings();
await settings.fetch();       // loads from localStorage

settings.set('theme', 'dark');
await settings.save();        // persists to localStorage

Supported backends: 'local' (localStorage), 'session' (sessionStorage), 'indexed' (IndexedDB), 'memory'.

Events

import { EventEmitter } from '@converse/skeletor';

class Store extends EventEmitter {}

const store = new Store();
store.on('update', (data) => console.log('Updated:', data));
store.trigger('update', { key: 'value' });

// Listen to another object's events (auto-cleaned up with stopListening)
const view = new EventEmitter();
view.listenTo(store, 'update', (data) => console.log('View saw:', data));
view.stopListening(); // removes all listeners set up via listenTo

// subscribe() returns an unsubscribe function
const unsub = store.subscribe('update', (data) => console.log(data));
unsub();

Integration with React

subscribe() is directly compatible with React's useSyncExternalStore:

import { useSyncExternalStore } from 'react';

function UserName({ user }) {
  const attrs = useSyncExternalStore(
    (cb) => user.subscribe(cb),  // subscribe — returns unsub
    () => user.toJSON()          // getSnapshot
  );
  return <span>{attrs.firstName}</span>;
}

Features at a Glance

Export What it provides
Model get/set, attrs proxy, computed properties, change tracking, validation, server sync
Collection Full array API plus where, findWhere, pluck, groupBy, keyBy, countBy, sortBy
EventEmitter on/off/trigger/once, listenTo/stopListening, subscribe() returning an unsubscribe function
BrowserStorage IndexedDB, localStorage, sessionStorage, and in-memory backends
sync Low-level Fetch-based HTTP function (override for custom transports)

Migrating from Backbone

If you have an existing Backbone project, you can migrate the data layer incrementally:

  1. Install Skeletor: npm install @converse/skeletor
  2. Replace import Backbone from 'backbone' with named imports: import { Model, Collection } from '@converse/skeletor'
  3. Replace Backbone.Model.extend({...}) with class MyModel extends Model { ... }
  4. Fix the handful of removed or renamed methods listed below
  5. Keep using Backbone for Views, Router, and History — they are unaffected

Your Views and Router don't need to change at all. Skeletor models and collections emit the same events Backbone does.

Changes from Backbone

Modernizations

  • Rewritten in TypeScript with full type definitions
  • Removed the dependency on jQuery
  • Replaced underscore with lodash-es, with individual imports for tree-shaking
  • Uses native browser APIs instead of lodash wherever possible
  • Drops support for older browsers (including IE); requires ES6+
  • All types (Model, Collection) are ES6 classes — use class extends instead of .extend()
  • Adds EventEmitter mixin class (replaces the old Events constructor function)
  • Async operations return Promises
  • ESM build available alongside CJS
  • attrs proxy for direct reactive attribute access
  • computed properties with caching and automatic change events
  • subscribe() returning an unsubscribe function on all reactive objects

What was removed

  • View and ElementView — manage your UI separately
  • Router and History — use the browser's History API or a dedicated router library
  • .extend() static method — use class MyModel extends Model instead
  • clone() on Model — use new MyModel(model.toJSON()) instead
  • chain() and escape() on Model
  • inject, foldl, foldr on Collection — use reduce instead
  • sample, take, tail, initial on Collection
  • without, reject, select on Collection — use filter instead
  • partition and invokeMap on Collection

Method renames (Underscore → Lodash)

Old (Underscore) New (Lodash)
rest drop
indexBy keyBy
invoke invokeMap (then removed — use map)
contains includes

Other behavioural changes

  • Collection.prototype.forEach no longer returns the iterated items. Use map instead.
  • Model.prototype.set returns null (not false) when validation fails.
  • Collection.prototype.create returns null (not boolean) on failure.

About

Models and Collections for modern web apps

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors