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.
- Reactive models — set an attribute, get a
changeevent. Subscribe with a callback and an unsubscribe function. Works with any UI layer. - Direct attribute access —
model.attrs.name = 'Bob'fires change events, no boilerplate needed. - Computed properties — declare derived values with explicit dependencies; they cache, recalculate, and fire
changeevents automatically. - Store-style subscriptions —
subscribe()returns an unsubscribe function, compatible with React'suseSyncExternalStoreand 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-compatible —
get,set, events, collections: all there. Drop-in replacement for Backbone's data layer.
npm install @converse/skeletor
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 upimport { 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();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 localStorageSupported backends: 'local' (localStorage), 'session' (sessionStorage), 'indexed' (IndexedDB), 'memory'.
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();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>;
}| 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) |
If you have an existing Backbone project, you can migrate the data layer incrementally:
- Install Skeletor:
npm install @converse/skeletor - Replace
import Backbone from 'backbone'with named imports:import { Model, Collection } from '@converse/skeletor' - Replace
Backbone.Model.extend({...})withclass MyModel extends Model { ... } - Fix the handful of removed or renamed methods listed below
- 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.
- 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 — useclass extendsinstead of.extend() - Adds
EventEmittermixin class (replaces the oldEventsconstructor function) - Async operations return Promises
- ESM build available alongside CJS
attrsproxy for direct reactive attribute accesscomputedproperties with caching and automatic change eventssubscribe()returning an unsubscribe function on all reactive objects
ViewandElementView— manage your UI separatelyRouterandHistory— use the browser's History API or a dedicated router library.extend()static method — useclass MyModel extends Modelinsteadclone()on Model — usenew MyModel(model.toJSON())insteadchain()andescape()on Modelinject,foldl,foldron Collection — usereduceinsteadsample,take,tail,initialon Collectionwithout,reject,selecton Collection — usefilterinsteadpartitionandinvokeMapon Collection
| Old (Underscore) | New (Lodash) |
|---|---|
rest |
drop |
indexBy |
keyBy |
invoke |
invokeMap (then removed — use map) |
contains |
includes |
Collection.prototype.forEachno longer returns the iterated items. Usemapinstead.Model.prototype.setreturnsnull(notfalse) when validation fails.Collection.prototype.createreturnsnull(notboolean) on failure.
