Small signals and state management for TypeScript, with no renderer attached.
Live playground: the site runs the library source, so the effect counts you see there are real.
NimbleJS is a reactive state layer: signals hold values, computed values derive from them, and effects run when something they read changes. On top of that sit small stores with optional undo/redo and persistence.
It does not render anything and the core never touches the DOM. That makes it usable in vanilla JavaScript, Web Components, workers, or as the state layer under your own framework adapter.
Status: pre-1.0. Good for experiments and small projects. The API may still change between minor versions, and there is no stability or performance guarantee yet.
npm install @onuracar-dev/nimblejsShips as ESM and CommonJS with type declarations. No runtime dependencies. Node 18 or newer.
import { computed, effect, signal } from '@onuracar-dev/nimblejs';
const count = signal(1);
const doubled = computed(() => count.value * 2);
const stop = effect(() => {
console.log(`count=${count.value}, doubled=${doubled.value}`);
});
// logs: count=1, doubled=2
count.value = 5;
// logs: count=5, doubled=10
stop();
doubled.dispose();Read with .value, write with .value = .... That is the whole model.
Dependencies follow the latest run. An effect subscribes to exactly the signals it read the last time it ran. If a branch stops reading a signal, changes to that signal stop waking the effect.
const name = signal('Ada');
const verbose = signal(false);
const count = signal(0);
effect(() => {
console.log(name.value);
if (verbose.value) console.log(count.value);
});
count.value = 1; // nothing runs: count was not read last timeCleanup is explicit. If an effect returns a function, it runs before the next run and once more when the effect is stopped.
const stop = effect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
});
stop(); // final cleanup runs exactly onceBatches settle before effects run. batch() holds notifications until the outermost callback
returns, then runs each affected effect once. Nested batches flush at the outer boundary.
import { batch } from '@onuracar-dev/nimblejs';
batch(() => {
first.value = 'Grace';
last.value = 'Hopper';
}); // effects reading first and last run once, after both writesComputed values settle first. When a write changes a computed value and also a signal an effect reads directly, the computed value updates before the effect runs, so the effect sees consistent values and runs once. (This was fixed after 0.2.0; see the changelog.)
Equal writes are ignored. Signals compare with Object.is, so writing the same value (or
NaN over NaN) notifies nobody.
Schedulers are optional. By default effects run synchronously. Pass a scheduler to hand the run to a microtask, an animation frame, or your framework's queue:
effect(render, { scheduler: (run) => queueMicrotask(run) });The scheduler owns de-duplication outside a batch.
A store is a named record of signals. Each key becomes a signal under store.state.
import { createStore, persist, withHistory } from '@onuracar-dev/nimblejs';
const settings = createStore('settings', { theme: 'dark', sidebarOpen: true });
settings.state.theme.value = 'light'; // write one key
settings.setRawState({ theme: 'dark', sidebarOpen: false }); // write several, as one batch
settings.getRawState(); // { theme: 'dark', sidebarOpen: false }
persist(settings, { storage: localStorage });
const history = withHistory(settings, { maxHistory: 25 });
history.undo();
history.redo();
history.dispose();persistloads saved state once, then writes JSON to the givenStorageon every change. Without astorageoption it useslocalStorageand throws if none exists. Read, parse and write errors go toonErrorif you pass one, otherwise toconsole.warn.withHistorykeeps bounded JSON snapshots (50 by default). Undo and redo are applied as one batch and are not recorded as new history.
| Export | Signature | Purpose |
|---|---|---|
signal |
signal(initial) → Signal<T> |
Mutable value with Object.is equality |
computed |
computed(fn) → Computed<T> |
Derived value; call dispose() to stop it |
effect |
effect(fn, { scheduler? }) → stop() |
Tracked side effect with optional cleanup |
batch |
batch(fn) → result of fn |
Coalesce notifications across nested writes |
createStore |
createStore(id, initial) → Store<T> |
Named record of signals with getRawState / setRawState |
persist |
persist(store, { storage?, key?, onError? }) → store |
Hydrate and save a store through Storage |
withHistory |
withHistory(store, { maxHistory? }) → controller |
undo, redo, clear, dispose, canUndo, canRedo, length |
- Render components or diff the DOM.
- Encrypt, validate or migrate persisted state, or sync it across tabs.
- Snapshot circular or non-JSON values in history.
- Return a stop handle from
persist; it saves for the lifetime of the store. - Detect effects that keep re-triggering each other.
FluxDOM is a separate compiler-first UI project with its own signal runtime. The two share tested reactive semantics but do not depend on each other. The reasoning is in ADR-0001.
npm ci
npm test # vitest
npm run build # typecheck, library build, CJS types
npm run pack:checkThe website lives in website/ and imports the library source directly:
npm --prefix website ci
npm --prefix website run devSee CONTRIBUTING.md and SECURITY.md.
MIT © 2026 Onur Acar