On this page
GitHub ↗

Introduction

State management with plain classes. Put state in properties, mutations in methods, and derived values in getters. Subscribe to the instance you render.

For agent ingestion, read the complete Markdown guide or the index.

0

Pick a framework. They all move the same count.

Install

Terminal
npm install exome

Import the core from exome. Import framework adapters from their own subpaths; install the matching framework separately. No provider, reducer, decorator, or compiler plugin is required.

Stores and actions

Example
import { Exome } from 'exome';

export class Counter extends Exome {
  count = 0;

  get doubled() {
    return this.count * 2;
  }

  increment() {
    this.count += 1;
  }
}

export const counter = new Counter();
counter.increment(); // count: 1, doubled: 2
  • Use prototype methods for actions. They are bound to the instance, so passing counter.increment as a callback preserves this.
  • Actions notify subscribers when they finish, even when no property changes. Exome does not track individual property reads or writes.
  • Getters compute on access; they are not memoized actions.
  • Arrow-function fields are not wrapped as actions. Direct assignments and array mutations outside an action do not notify subscribers.
  • A custom constructor must call super(). Keep one instance per intended lifetime: shared for app state, local for isolated state, and per request on a server.

Async actions

An async action notifies when its returned promise settles, not after each assignment or await. Call update(this) when an intermediate state must be visible. Errors still reject the returned promise.

Example
import { Exome, update } from 'exome';

export class Profile extends Exome {
  name = '';
  loading = false;

  async load(readName: () => Promise<string>) {
    this.loading = true;
    update(this);
    try {
      this.name = await readName();
    } finally {
      this.loading = false;
    }
  }
}

const profile = new Profile();
await profile.load(async () => 'Ada');

For concurrent requests, handle cancellation or stale results in the action. Exome does not order requests for you. Catch failures at the caller or store an explicit error state.

Nested stores

Model independent entities as child stores. A child action notifies that child, not its parent. Subscribe where each instance is rendered; subscribe to the parent for additions, removals, and replacements.

Example
import { Exome } from 'exome';

export class Todo extends Exome {
  done = false;
  constructor(public title = '') { super(); }
  toggle() { this.done = !this.done; }
}

export class TodoList extends Exome {
  items: Todo[] = [];
  add(title: string) { this.items.push(new Todo(title)); }
  remove(todo: Todo) {
    this.items = this.items.filter(item => item !== todo);
  }
}

Render the list with a parent subscription and each row with a child subscription. A parent getter that reads child state does not create a dependency subscription. Keep derived UI subscribed to every instance it reads.

Subscribe and integrate

subscribe(store, callback) returns an unsubscribe function. It does not call the callback immediately. Render once yourself, then subscribe. Release the subscription when the view is destroyed.

Example
import { subscribe } from 'exome';
import { counter } from './counter.js';

const render = () => { console.log(counter.count); };
render();
const stop = subscribe(counter, render);
counter.increment();
stop();

Use update(store) after an external mutation when an action is impractical. updateAll() notifies all currently subscribed instances; prefer targeted updates.

React and Preact

Use useStore(store) inside the component and keep the instance stable across renders. See exome/react and exome/preact for signatures and examples.

Other adapters

ImportUseRead the value
exome/radiuseStore(store, s => s.count)A subscribable value: pass it directly as a Radi child or attribute.
exome/vueuseStore(store) in setupDestructure returned refs; templates unwrap them. Nested stores need their own subscriptions.
exome/litnew StoreController(this, store)controller.store; connects and disconnects with the host.
exome/svelteuseStore(store, s => s.count)A Svelte store: $count in a component, or .subscribe(fn).
exome/soliduseStore(store, s => s.count)An accessor: count() in a reactive expression.
exome/angularuseStore(store, s => s.count) in an injection contextA signal: count() in the template.
exome/rxjsobservableFromExome(store)Emits the store immediately and on updates.

For Solid and Angular, select the primitive or changed reference you render. Returning the same mutable store reference can suppress updates through equality checks.

In 2.9.0, the Vue, Solid, Angular, and RxJS adapters do not reliably release the underlying Exome subscription on teardown. For short-lived views, use subscribe with your framework's explicit cleanup hook. With RxJS, return the Exome cleanup from an Observable's subscription function. Verify lifecycle behavior when upgrading.

Action effects and middleware

onAction(StoreClass, action, callback, timing?) observes all instances of that class. Use null to observe all actions. Timing is 'after' by default or 'before'. The callback receives (instance, action, payload, error, response); async after-effects run when the action settles.

Example
import { onAction } from 'exome';
import { Counter } from './counter.js';

const stop = onAction(Counter, 'increment', store => {
  console.log(store.count);
});
// Call stop() when the effect's owner is disposed.

addMiddleware(fn) observes every store. Register before constructing stores if you need creation events. Its callback receives (instance, action, payload) before an action; return (error, response) => void for completion. Both APIs return cleanup functions. Call each cleanup once.

NEW and LOAD_STATE are lifecycle events. NEW completion runs in a microtask after construction. runMiddleware(store, action, payload) manually starts the middleware chain and returns its completion callback; call that callback once. Prefer normal methods for application actions.

Save and load

Example
import { registerLoadable, saveState, loadState } from 'exome/state';
import { Todo, TodoList } from './todo.js';

registerLoadable({ Todo, TodoList });
const list = new TodoList();
list.add('Ship the docs');
const snapshot = saveState(list);
list.items[0].toggle();
loadState(list, snapshot); // restores done: false
  • saveState(store, readable?) returns JSON with Exome instance IDs. Pass true for indentation.
  • Register every nested store class before saving and loading. Stable registration names survive class-name minification.
  • loadState(existingRoot, json) mutates the root, reconstructs nested stores, and notifies subscribers. Recreated nested instances can have different object identities.
  • Nested constructors must work without arguments and should avoid side effects. Use default constructor parameters.
  • Treat snapshots as application data: validate their origin and schema. Serialization follows JSON limitations; functions, symbols, undefined, and arbitrary class prototypes are not persisted.
  • Scope stores per request for SSR. Never share user state through a process-wide singleton.

Debug and test

Register developer tools before creating stores. Enable them only in development.

Example
import { addMiddleware } from 'exome';
import { exomeReduxDevtools } from 'exome/devtools';

const stop = addMiddleware(exomeReduxDevtools({ name: 'App' }));

exomeReduxDevtools({ name?, maxAge?, actionsBlacklist? }) connects to Redux DevTools. unstableExomeDevtools({ name, maxAge?, ignoreListActions?, ignoreListStores? }) connects to Exome Developer Tools and remains experimental.

Test stores without a UI. Use a fresh instance per test, call an action, await async actions, and assert observable state. For notification behavior, subscribe before the action and release the subscription afterward. Use real Exome stores when testing actions; GhostExome from exome/ghost only provides an ID and does not wrap methods or notify.

API notes

The reference below covers every public JavaScript entry point in Exome 2.9.0. Signatures show the calling contract; T is your store type and R is a selected value. Framework types come from the corresponding framework. Examples that import ./counter.js use the Counter module in Stores and actions; ./todo.js uses Nested stores.

Use Ctrl/Cmd F to search this page. The complete reference and examples are also in index.md, without navigation or a JavaScript requirement.

exome

Stores and notifications

Export / signatureContract
class Exome / new Exome()Extend it to create a store. Prototype methods become bound actions. See Stores and actions.
subscribe<T extends Exome>(store: T | null | undefined, callback: (store: T) => void): () => voidSubscribe to one instance. No initial callback. Returns cleanup; nullish stores return a no-op cleanup.
update(store: Exome): voidNotify the instance's subscribers immediately. Does not run action middleware.
updateAll(): voidNotify subscribers of all instances. Prefer update for a known store.
Example
import { subscribe, update } from 'exome';
import { counter } from './counter.js';

const stop = subscribe(counter, store => console.log(store.count));
counter.count = 10; // assignment alone does not notify
update(counter);   // logs 10
stop();

Action observation

Export / signatureContract
onAction(StoreClass, action, callback, timing?): () => voidObserve all instances of the class. action is a method key, 'NEW', 'LOAD_STATE', or null for every action. timing is 'before' or 'after' (default).
type Middleware = (store: Exome, action: string, payload: any[]) => void | ((error?: Error, response?: any) => void)The first callback runs before an action. Its optional returned callback runs after completion, including rejection or a thrown error.
addMiddleware(middleware: Middleware): () => voidRegister global middleware; returns cleanup. Register before store creation to see NEW.
runMiddleware(store: Exome, action: string, payload: any[]): (error?: Error, response?: any) => voidStart a manual middleware event. Call the returned completion once. Completion also notifies the store, except for NEW.

The onAction callback is (instance, action, payload, error?, response?) => void. Payload types are inferred from the method where possible. Before-callbacks have no completion result. After-callbacks wait for returned promises. These observers do not cancel or replace an action's result. Invoke cleanup once when the observer's owner is disposed.

Example
import { addMiddleware } from 'exome';
import { Counter } from './counter.js';

const stop = addMiddleware((store, action, payload) => {
  if (!(store instanceof Counter) || action !== 'increment') return;
  return (error, response) => {
    console.log({ action, payload, error, response, count: store.count });
  };
});
const store = new Counter();
store.increment();
stop();

Identity

Export / signatureContract
getExomeId(store: Exome): stringRead the ID, normally ClassName-generatedSuffix.
setExomeId(store: Exome, id: string): voidReplace only the suffix, preserving the class prefix. Set it before subscriptions; existing subscriptions remain keyed by the old ID. Keep IDs unique.
exomeId: unique symbolInternal instance-ID key. Prefer the ID helpers.
exomeName: unique symbolClass-name key. Prefer registerLoadable for stable serialized names.
Example
import { getExomeId, setExomeId } from 'exome';
import { Counter } from './counter.js';

const store = new Counter();
setExomeId(store, 'sidebar');
console.log(getExomeId(store)); // Counter-sidebar

exome/state

Export / signatureContract
registerLoadable(classes: Record<string, any>): voidRegister name-to-constructor mappings for nested stores. Use stable names before saving and loading.
saveState(store: Exome, readable?: boolean): stringSerialize state and Exome identities to JSON. readable defaults to false; true indents with two spaces.
loadState(store: Exome, state: string): anyRestore into an existing root and return the parsed root data without its ID. Rebuild nested instances and notify subscribers. Throws for invalid snapshots, missing registrations, or constructors that cannot run without arguments.

See the complete save/load example. Restoration is assignment into the existing root, not replacement: properties absent from the snapshot are not deleted. JSON limitations apply; validate snapshots before loading them.

exome/utils

getActionStatus<E = Error, T extends Exome = any, R = any>(store: T, action: keyof T) returns a cached status object:

PropertyMeaning
loading: booleantrue while the latest observed invocation is pending.
error: false | Efalse initially and on success; otherwise the completion error.
response: void | RThe latest result. In 2.9.0, falsy values become undefined; use the action return value if 0, false, or '' matters.
unsubscribe(): voidRemove the shared observer and clear its cache entry. Call once when all consumers are finished.

Create the status before invoking the action. Status changes notify the associated store. Tracking the latest call does not cancel earlier calls or prevent their state changes.

Example
import { Exome } from 'exome';
import { getActionStatus } from 'exome/utils';

class Search extends Exome {
  async run() { return ['result']; }
}
const search = new Search();
const status = getActionStatus(search, 'run');
const pending = search.run();
console.log(status.loading); // true
await pending;
console.log(status.loading, status.response); // false, ['result']
status.unsubscribe();

exome/devtools

Both exports return Middleware; pass the result to addMiddleware before constructing stores. Enable only in development with the matching browser extension installed. See Debug and test.

ExportConfiguration
exomeReduxDevtools(config): Middleware{ name?: string, maxAge?: number, actionsBlacklist?: string }. Redux DevTools integration; returns inert middleware if the extension is unavailable.
unstableExomeDevtools(config): Middleware{ name: string, maxAge?: number, ignoreListActions?: string[], ignoreListStores?: string[] }. Experimental Exome Developer Tools integration. name is required.
Example
import { addMiddleware } from 'exome';
import { unstableExomeDevtools } from 'exome/devtools';

const stop = addMiddleware(unstableExomeDevtools({ name: 'App' }));
// Call stop() when this development session is disposed.

exome/ghost

class GhostExome supplies an exomeId but does not wrap actions, notify subscribers, or run middleware. It does not extend Exome. Use it for inert test fixtures, not to verify real store behavior.

Example
import { GhostExome } from 'exome/ghost';

class CounterFixture extends GhostExome {
  count = 3;
}
const fixture = new CounterFixture();
console.log(fixture.count); // 3

The ID member is private in the published TypeScript declaration. Read fixture data directly; use real stores when you need public ID helpers.

exome/radi

Added in Exome 2.9.0. useStore creates a subscribable value for Radi children and attributes. Install Radi separately; the adapter adds no runtime dependency and is verified with Radi 0.7.1.

ExportContract
useStore<T extends Exome, R = T>(store: T, selector?: (state: T) => R): { subscribe(callback: (value: R) => void): () => void }Each subscriber receives the selected value immediately, then after store notifications. Without a selector, it receives the store itself.
Example
import { createRoot } from 'radi';
import { useStore } from 'exome/radi';
import { counter } from './counter.js';

function App() {
  const count = useStore(counter, store => store.count);
  const title = useStore(counter, store => `Count: ${store.count}`);
  return <button onclick={counter.increment} title={title}>{count}</button>;
}

createRoot(document.getElementById('app')!).render(<App />);

Configure TypeScript JSX with "jsx": "react-jsx", "jsxImportSource": "radi", and "types": ["node_modules/radi/jsx.d.ts"]. Keep the store instance stable. Pass the subscribable directly to Radi; it is not a snapshot to destructure or an accessor to call.

Radi releases each subscription when its consuming element is removed. Subscribe separately to nested Exome stores whose actions you want to observe. Async actions notify on settlement; use update(store) for intermediate state, as with core subscriptions. The adapter does not filter equal selected values.

Manual Radi subscriptions

Outside a rendered element, call the returned cleanup yourself:

Example
import { useStore } from 'exome/radi';
import { counter } from './counter.js';

const count = useStore(counter, store => store.count);
const values: number[] = [];
const stop = count.subscribe(value => values.push(value)); // current count
counter.increment(); // another value
stop();
counter.increment(); // no further values collected

exome/react

useStore<T extends Exome | null | undefined>(store: T): Readonly<T> subscribes the component and returns the same instance. It releases its subscription on unmount or when the store argument changes. Nullish input is returned unchanged. Install React separately.

Example
import { useStore } from 'exome/react';
import { counter } from './counter.js';

export function CounterView() {
  const { count, increment } = useStore(counter);
  return <button onClick={increment}>{count}</button>;
}

Keep instances stable across renders. Subscribe each row to its own nested store; a parent subscription does not include child updates.

exome/preact

useStore<T extends Exome | null | undefined>(store: T): Readonly<T> has the same contract as the React adapter, using Preact hooks. Install Preact separately.

Example
import { useStore } from 'exome/preact';
import { counter } from './counter.js';

export function CounterView() {
  const { count, increment } = useStore(counter);
  return <button onClick={increment}>{count}</button>;
}

exome/vue

useStore<T extends Exome>(store: T): Readonly<T> is the published signature. At runtime it returns a proxy: data fields and getters become Vue refs, methods stay callable, and nested Exome instances are returned directly. The declaration does not accurately describe those refs. Call in Vue setup; install Vue separately.

Example
<script setup lang="ts">
import { useStore } from 'exome/vue';
import { counter } from './counter.js';
const { count, increment } = useStore(counter);
</script>

<template>
  <button @click="increment()">{{ count }}</button>
</template>

Templates unwrap refs. Subscribe separately to nested stores. In 2.9.0 the adapter does not register its Exome cleanup with Vue; use subscribe plus onScopeDispose for short-lived views requiring reliable teardown.

exome/lit

new StoreController<T extends Exome>(host: ReactiveControllerHost, store: T) registers a controller with the host. Read controller.store: T. hostConnected(): void subscribes and requests host updates; hostDisconnected(): void unsubscribes. Lit calls these lifecycle methods. Install Lit separately.

Example
import { LitElement, html } from 'lit';
import { StoreController } from 'exome/lit';
import { counter } from './counter.js';

class CounterView extends LitElement {
  controller = new StoreController(this, counter);
  render() {
    const { count, increment } = this.controller.store;
    return html`<button @click=${increment}>${count}</button>`;
  }
}
customElements.define('counter-view', CounterView);

Add <counter-view></counter-view> to the page. Keep the controller's store stable; assigning another store does not automatically rewire its subscription.

exome/svelte

useStore<T extends Exome, R = T>(store: T, selector?: (state: T) => R) returns { subscribe(callback: (value: R) => void): () => void }. It emits immediately, then on store updates. The returned cleanup releases the Exome subscription. Without a selector it emits the store itself.

Example
<script lang="ts">
import { useStore } from 'exome/svelte';
import { counter } from './counter.js';
const count = useStore(counter, store => store.count);
</script>

<button on:click={counter.increment}>{$count}</button>

This uses Svelte's store syntax (Svelte 3/4 and Svelte 5 legacy mode). Component auto-subscriptions clean up on destruction.

exome/solid

useStore<T extends Exome, R = T>(store: T, selector?: (state: T) => R): Accessor<R> creates a Solid signal initialized from the store. Read the returned accessor in a reactive expression. Install Solid separately and call inside a reactive owner.

Example
import { useStore } from 'exome/solid';
import { counter } from './counter.js';

export function CounterView() {
  const count = useStore(counter, store => store.count);
  return <button onClick={counter.increment}>{count()}</button>;
}

Select a primitive or changed reference: the default selector returns the same mutable instance, so signal equality can suppress updates. In 2.9.0 its cleanup callback returns the unsubscribe function instead of calling it. For short-lived owners, use core subscribe with onCleanup(stop).

exome/angular

useStore<T extends Exome, R = T>(store: T, selector?: (state: T) => R): Signal<R> returns a read-only Angular signal. Call in an injection context (for example, a component field initializer). Install Angular separately.

Example
import { Component } from '@angular/core';
import { useStore } from 'exome/angular';
import { counter } from './counter.js';

@Component({
  selector: 'counter-view',
  standalone: true,
  template: '<button (click)="increment()">{{ count() }}</button>',
})
export class CounterView {
  count = useStore(counter, store => store.count);
  increment = counter.increment;
}

Select a primitive or changed reference so signal equality detects updates. In 2.9.0 the adapter does not register destruction cleanup correctly. For short-lived components, subscribe explicitly and register the returned cleanup with DestroyRef.onDestroy.

exome/rxjs

observableFromExome<T extends Exome = Exome>(store: T): Observable<T> emits the instance immediately on subscription and after updates. Values are the same mutable instance, not snapshots. Install RxJS separately; map to a field before using distinctUntilChanged.

In 2.9.0 the built-in Observable does not return the Exome teardown. For subscriptions that must be released, use this equivalent with explicit cleanup:

Example
import { Observable, map, distinctUntilChanged } from 'rxjs';
import { subscribe } from 'exome';
import { counter } from './counter.js';

const state$ = new Observable<typeof counter>(subscriber => {
  subscriber.next(counter);
  return subscribe(counter, store => subscriber.next(store));
});
const subscription = state$
  .pipe(map(store => store.count), distinctUntilChanged())
  .subscribe(count => console.log(count));
counter.increment();
subscription.unsubscribe();

exome/package.json is also exported for package metadata; it is not a JavaScript API module. JSON import syntax depends on your runtime and bundler.

Troubleshooting

  • No render after a mutation: use a prototype method, or call update(store) after an external mutation. Arrow fields do not become actions.
  • Child changed but parent stayed stale: subscribe to the child as well. Updates do not bubble through the store tree.
  • Loading indicator never appears: publish the intermediate state with update(this), or create getActionStatus before the action starts.
  • State resets on render: stop constructing the store on every render.
  • A snapshot will not load: register nested classes with stable names and make their constructors callable without arguments.
  • Leaked subscriptions: own each cleanup explicitly; check the adapter limitations above.

Source of truth: Exome source, package releases, and your installed package's types.