feature-state
v0.1.1
Published
Reactive state with computed values and opt-in features for undo, storage, equality, and queues
Downloads
236
Maintainers
Readme
feature-state is reactive state that grows by installing features. Start with one observable value, derive read-only values with createComputed(), then add undo, storage, custom equality, queues, or custom capabilities with .with() only where you need them.
- Install capabilities per state: undo, storage, queues, and equality stay opt-in
- Share the same framework-free state object across vanilla JS, Node.js, tests, and UI frameworks
- Let TypeScript track installed capabilities:
undo()exists only afterundoFeature() - Build custom feature packs on the same typed host model as the built-ins
import { createComputed, createState, undoFeature } from 'feature-state';
type TTask = { id: number; title: string; done: boolean };
const $tasks = createState<TTask[]>([]).with(undoFeature());
const $openTasks = createComputed($tasks, (tasks) => tasks.filter((task) => !task.done));
const unlisten = $openTasks.listen(({ value }) => {
console.log(value);
});
$tasks.set([{ id: 1, title: 'Buy milk', done: false }]);
$openTasks.get(); // [{ id: 1, title: 'Buy milk', done: false }]
$tasks.undo(); // back to []
unlisten();Migrating from 0.0.x? See MIGRATION.md.
Install
npm install feature-stateUsage
A state holds a single value and notifies listeners when it changes:
import { createState } from 'feature-state';
const $count = createState(0);
$count.listen(({ value, prevValue }) => {
console.log(value, prevValue);
});
$count.set(5);
$count.set((v) => v + 1); // updater form, value is now 6Extend a state with features using .with(). Each installed feature adds typed methods:
import { createState, multiUndoFeature, undoFeature } from 'feature-state';
const $count = createState(0).with(undoFeature(), multiUndoFeature());
$count.set(1);
$count.set(2);
$count.set(3);
$count.undo(); // 2
$count.multiUndo(2); // 0Derive a read-only value from one or more states with createComputed. It refreshes when read or observed:
import { createComputed, createState } from 'feature-state';
type TTask = { id: number; title: string; done: boolean };
const $tasks = createState<TTask[]>([]);
const $done = createComputed($tasks, (tasks) => tasks.filter((t) => t.done));
$tasks.set([{ id: 1, title: 'Buy milk', done: true }]);
$done.get(); // [{ id: 1, title: 'Buy milk', done: true }]Persist state across sessions with storageFeature. Pass any storage adapter that implements save, load, and delete:
import { createState, missingStorageValue, storageFeature } from 'feature-state';
const localAdapter = {
save: (key: string, value: unknown) => {
localStorage.setItem(key, JSON.stringify(value));
return true;
},
load: (key: string) => {
const raw = localStorage.getItem(key);
return raw != null ? JSON.parse(raw) : missingStorageValue;
},
delete: (key: string) => {
localStorage.removeItem(key);
return true;
}
};
const $tasks = createState<string[]>([]).with(storageFeature(localAdapter, 'tasks'));
await $tasks.persist(); // loads saved value on first call; auto-saves on every set()State
createState(initialValue)
Creates a state container and returns it as a feature host.
const $count = createState(0);
const $status = createState<'idle' | 'loading' | 'error'>('idle');value / get() / set()
$count.value; // 0
$count.get(); // 0
$count.set(5);
$count.set((v) => v + 1); // updater form
$count.value = 10; // same as set(10)set() skips updating and notifying when the new value is identical to the current one (Object.is comparison).
notify()
Triggers all listeners without replacing the value. Use this after mutating an object value in place, or when a feature updates internal state by other means.
const $settings = createState({ theme: 'light', sidebarOpen: true });
const prevValue = { ...$settings.value };
$settings.value.theme = 'dark'; // mutate in place, no notification yet
$settings.notify({ prevValue }); // notify listeners manuallyUse _value only inside features or low-level integrations. App code should prefer set(), value, get(), and notify().
Pass custom metadata to every listener in the same notification:
$count.notify({ listenerContext: { source: 'mySync', background: true } });listen(callback) / subscribe(callback)
listen registers a callback for future changes and returns an unsubscribe function. subscribe does the same but also calls the callback immediately with the current value.
const unlisten = $count.listen(({ value, prevValue, source }) => {
console.log(value, prevValue, source);
});
unlisten(); // remove listenerCalling unlisten() inside the listener itself is safe. Any pending call to that callback in the current notification cycle is removed immediately.
Listener context
| Field | Description |
| ------------ | ---------------------------------------------------------------------------------------- |
| value | The new value. |
| prevValue | The previous value. Undefined when notify() is called without a prior value. |
| source | What triggered the change. 'stateSet' for set(). Features set their own source keys. |
| background | When true, signals that the change is a background sync and UI updates can be skipped. |
Listeners run synchronously in registration order. A nested set() inside a listener appends its listeners to the active queue. They run after already queued listeners, before the outermost set() or notify() returns. Queue features replace this behavior.
createComputed(source, compute, options?)
Creates a read-only state derived from one source state:
import { createComputed, createState } from 'feature-state';
type TTask = { done: boolean; category: string };
const $tasks = createState<TTask[]>([]);
const $completedCount = createComputed($tasks, (tasks) => tasks.filter((task) => task.done).length);Pass a tuple when the value depends on multiple states:
const $filter = createState('work');
const $filteredTasks = createComputed([$tasks, $filter] as const, ([tasks, filter]) =>
tasks.filter((task) => task.category === filter)
);Computed states support get(), value, listen(), and subscribe(). Calling set() or assigning value throws. Queue features can be installed on computed states, but keep write-oriented features on their sources.
Sources stay subscribed while the computed state has listeners. With no listeners, reads refresh the cached value when sources change. Keep computation callbacks pure.
isEqual defaults to Object.is. Pass a custom comparator to compare computed values, or false to notify on every source update while observed.
Built-in Features
Features are installed via .with() and extend the state with new methods.
undoFeature(historyLimit?)
Adds undo(). Keeps the last 50 values by default. History is seeded with the initial value at install time.
import { createState, undoFeature } from 'feature-state';
const $count = createState(0).with(undoFeature());
$count.set(1);
$count.set(2);
$count.undo(); // 1
$count.undo(); // 0
$count.undo(); // no-op, already at oldestmultiUndoFeature()
Adds multiUndo(count). Requires undoFeature to be installed first.
import { createState, multiUndoFeature, undoFeature } from 'feature-state';
const $count = createState(0).with(undoFeature(), multiUndoFeature());
$count.set(1);
$count.set(2);
$count.set(3);
$count.multiUndo(2); // back to 1storageFeature(storage, key)
Adds persist(), loadFromStorage(), and deleteFromStorage(). The storage adapter is a plain object with save, load, and delete methods.
import { createState, missingStorageValue, storageFeature } from 'feature-state';
const storage = {
save(key: string, value: unknown) {
localStorage.setItem(key, JSON.stringify(value));
return true;
},
load(key: string) {
const raw = localStorage.getItem(key);
return raw != null ? JSON.parse(raw) : missingStorageValue;
},
delete(key: string) {
localStorage.removeItem(key);
return true;
}
};
const $tasks = createState<string[]>([]).with(storageFeature(storage, 'tasks'));
await $tasks.persist();persist() loads any previously saved value. If nothing is stored it saves the current state instead, then auto-saves on every subsequent set(). Calling persist() more than once is safe.
TStorageInterface contract: load must return missingStorageValue (a Symbol) when the key is absent. null and undefined are treated as valid stored values.
isEqualFeature(isEqual)
Overrides set() with a domain-specific equality check. Use it when reference equality would notify listeners even though the visible state did not change.
import { createState, isEqualFeature } from 'feature-state';
const $status = createState({ type: 'valid' }).with(
isEqualFeature((prevValue, nextValue) => prevValue.type === nextValue.type)
);asyncQueueFeature()
Replaces the default sync listener queue with a microtask-based FIFO queue. Listeners still run in registration order, but after the current call stack resolves. Async listeners are awaited one by one.
import { asyncQueueFeature, createState } from 'feature-state';
const $count = createState(0).with(asyncQueueFeature<number>());
$count.listen(async ({ value }) => {
await fetch('/api/count', {
method: 'POST',
body: JSON.stringify({ value })
});
});
await $count.notify(); // resolves when all listeners have completednotify() returns the active queue flush promise. Multiple notify() calls before the microtask fires share the same promise. set() still returns void, so listener errors from set() are not awaitable through set() itself.
priorityQueueFeature()
Replaces the default sync listener queue with a priority-based sync queue. Lower priority values run first. Listeners with the same priority keep registration order.
import { createState, EListenerPriority, priorityQueueFeature } from 'feature-state';
const $count = createState(0).with(priorityQueueFeature<number>());
$count.listen(() => {}, { priority: EListenerPriority.LATE });
$count.listen(() => {}, { priority: EListenerPriority.EARLY }); // runs firstEListenerPriority provides named constants: FIRST = 0, EARLY = 125, DEFAULT = 250, LATE = 375, LAST = 500. Any number is valid.
Extending with Features
States are feature-core feature hosts. Add behavior with .with(yourFeature()). See the feature-core README for a full guide on defineFeature(), dependency declaration, and the feature model.
Examples
FAQ
How does it compare to Nanostores, Zustand, and MobX?
feature-state is centered on composable feature hosts. Use it when you want state objects that can gain typed capabilities over time without committing to proxies, decorators, or a React-specific store model.
- nanostores: framework-agnostic atom-based state with framework integrations
- zustand: store-based state management, primarily for React
- MobX: reactive state via proxies and decorators, class-oriented
Why does set() skip notification when the value is the same?
Skipping on reference equality (Object.is) prevents redundant re-renders and listener calls. To force a notification without changing the value, call notify() directly.
Why does subscribe() pass prevValue equal to value on the initial call?
The initial call has no prior state, so prevValue is set to the current value. Listeners never receive undefined for prevValue and can be written without a null check.
Is it safe to unsubscribe inside a listener?
Yes. The unsubscribe function removes the callback from _listeners and also removes any pending call to that callback already queued in the current notification cycle. The listener will not fire again even if notify() is still draining.
Can I combine asyncQueueFeature and priorityQueueFeature?
No. Both features override the same internal queue (listen, subscribe, and notify). Installing both means the last one installed takes effect and the first is silently ignored. Pick one.
When should I use _value directly instead of set()?
Use _value only inside features or low-level integrations that need raw backing-value access. For app code that mutates an object in place, use value or get() to reach the object, keep your own prevValue when listeners need it, then call notify(). This is an escape hatch; prefer replacing the value with set() when possible.
How does storageFeature prevent save loops?
When loadFromStorage() calls set() internally it passes source: 'loadFromStorage' in the listener context. The auto-save listener ignores changes with that source, so loading a value does not immediately write it back to storage.
What happens if a listener throws?
With the default queue and priorityQueueFeature, synchronous listener errors propagate from set() or notify() and stop the current flush. Async rejections are not awaited unless you use asyncQueueFeature().
With asyncQueueFeature(), notify() returns a promise that rejects when a listener rejects. set() still returns void, so catch errors inside listeners triggered by set().
Does .with() create a new state?
No. .with() installs features on the same state object and returns that object with a wider TypeScript type. Install features before sharing references that expect the added methods.
