npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

feature-state

v0.1.1

Published

Reactive state with computed values and opt-in features for undo, storage, equality, and queues

Downloads

236

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 after undoFeature()
  • 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-state

Usage

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 6

Extend 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); // 0

Derive 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 manually

Use _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 listener

Calling 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 oldest

multiUndoFeature()

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 1

storageFeature(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 completed

notify() 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 first

EListenerPriority 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.