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

@jucie.io/reactive

v1.1.3

Published

Fine-grained reactivity with signals, computed values and effects, plus optional Vue and React entry points

Downloads

1,552

Readme

@jucie.io/reactive

Fine-grained reactive programming with signals, computed values, and reactive stores.

Features

  • Signals: Simple reactive values with automatic dependency tracking
  • Computed: Computed values that auto-track and update when dependencies change
  • Bindings: Reactive values with explicit dependencies and context support
  • Sources: Remote data bindings with URL building and reactive shells
  • Stores: Component-like reactive contexts with state management
  • Subscribers: Side effects that run when reactive values change
  • Async Support: Native support for async computations
  • Batched Updates: Efficient change propagation

Installation

npm install @jucie.io/reactive

Quick Start

Signals

Reactive primitive values:

import { createSignal } from '@jucie.io/reactive';

const count = createSignal(0);

console.log(count()); // 0
count(5);
console.log(count()); // 5

// Update based on current value
count.update(n => n + 1);
console.log(count()); // 6

// Vue-style .value works too
count.value = 10;
console.log(count.value); // 10

Calling a signal with an argument (or assigning .value) stores that value exactly as given, functions included, so a signal can hold a callback or render function:

const render = createSignal(null);
render(() => h(Panel)); // stores the function, doesn't call it

Use update(fn) to derive the next value from the current one. If fn returns undefined the value is left as is, which lets you mutate arrays and objects in place (the mutation itself notifies dependents):

const list = createSignal([1, 2]);
list.update(l => [...l, 3]);  // replace
list.update(l => { l.push(4); }); // mutate in place
list.value.push(5);               // same, through .value

Setting a value that is Object.is-equal to the current one is a no-op and doesn't notify dependents. undefined is a valid value to set.

Computed Values

Automatically computed values that track dependencies:

import { createSignal, createComputed } from '@jucie.io/reactive';

const count = createSignal(0);
const doubled = createComputed(() => count() * 2);

console.log(doubled()); // 0
count(5);
console.log(doubled()); // 10
console.log(doubled.value); // 10

doubled.value = 1; // throws: computed values are read-only

Stores

Declarative reactive components with automatic value unwrapping:

Key Concepts:

  • Unwrapped Values: Returned signals and computed values are automatically unwrapped. Access them as properties (e.g., counter.count, counter.doubled) instead of calling them as functions.
  • Context (ctx): Inside computed values, the ctx parameter provides access to the unwrapped values of only what was returned from the store. This creates a clean API boundary between public and private state.
  • Actions (this): Actions are bound to the store. Use this to read and write returned state; caller arguments map directly to function parameters.
import { defineStore } from '@jucie.io/reactive';

const useCounter = defineStore(({ signal, computed, action, source }) => {
  const count = signal(0);
  const updateCount = signal(0); // Private signal - NOT in ctx / this

  const doubled = computed((ctx) => {
    // ctx.count is available because 'count' is returned
    // ctx.updateCount is NOT available because 'updateCount' is not returned
    return ctx.count * 2
  })

  const increment = action(function () {
    // Private signals stay available via closure
    updateCount.update(prevUpdateCount => prevUpdateCount + 1)
    // Returned state is available on this
    this.count++
  })

  return {
    count,    // Exposed publicly
    doubled,  // Exposed publicly
    increment // Exposed publicly
    // updateCount is NOT returned, so it's private
    // source(...) can fetch remote data with the same store context
  }
})

// Object config also supports a sources block:
const useFeed = defineStore({
  sources: {
    feed: {
      url: '/api/feed',
      shape: { title: '', body: '' },
      alwaysRefresh: true,
      fetch: customFetch,
    },
  },
});

const counter = useCounter();
console.log(counter.count); // 0 - accessed as property, not counter.count()
console.log(counter.doubled); // 0 - accessed as property, not counter.doubled()

counter.increment();
console.log(counter.count); // 1
console.log(counter.doubled); // 2

Framework integration

defineStore is framework-agnostic. For Vue and React apps, use the extension packages:

| Package | API | Use when | |---|---|---| | @jucie.io/reactive-vue | defineVueStore, createReactiveRef | Vue 3 components | | @jucie.io/reactive-react | defineReactStore, useReactive | React 18 components |

// Vue
import { defineVueStore } from '@jucie.io/reactive-vue';

export const useCounter = defineVueStore((setup) => {
  const count = setup.signal(0);
  const increment = setup.action(function () { this.count++; });
  return { count, increment };
});

// In <script setup>: const counter = useCounter();

Scene-graph runtime stores and non-UI code should keep using defineStore from @jucie.io/reactive directly.

Subscribers

Side effects for reactive values:

import { createSignal, createSubscriber } from '@jucie.io/reactive';

const count = createSignal(0);

createSubscriber(
  () => count(),
  (value) => console.log('Count changed:', value)
);

count(1); // Logs: "Count changed: 1"
count(2); // Logs: "Count changed: 2"

Advanced Features

Bindings

Reactive values with explicit dependencies:

import { createSignal, createBinding } from '@jucie.io/reactive';

const firstName = createSignal('John');
const lastName = createSignal('Doe');

// Explicit dependencies - function receives (context, previousValue)
const fullName = createBinding(
  (ctx, prev) => `${firstName()} ${lastName()}`,
  [firstName, lastName]
);

console.log(fullName()); // "John Doe"
lastName('Smith');
console.log(fullName()); // "John Smith"

Async Bindings

Bindings work seamlessly with async functions:

import { createSignal, createBinding } from '@jucie.io/reactive';

const userId = createSignal(1);

const userData = createBinding(
  async (ctx, prev) => {
    const response = await fetch(`/api/users/${userId()}`);
    return response.json();
  },
  [userId]
);

// Returns a promise
const data = await userData();

Sources

Sources are async bindings for remote data. They build a URL, fetch it, and hydrate the response into a reactive shell — a stable object whose keys are backed by signals.

The second argument is the source's shape: an object whose keys are the server's field names.

import { createSignal, createSource, refreshSource } from '@jucie.io/reactive';

const userId = createSignal(1);
const page = createSignal(1);

const user = createSource(
  '/api/users/:userId',
  {
    id: null,                                            // field's default value
    display_name: 'Anonymous',
    user_name: (value) => value.toUpperCase(),           // shorthand: get only
    price: { default: 0, get: (v) => v / 100, set: (v) => v * 100 },
  },
  { userId },
  {
    baseUrl: 'https://api.example.com',
    query: { page },
    auth: 'Bearer token',
  }
);

// Array params append path segments
const subscription = createSource(
  '/api/users',
  { plan: 'free', renews_at: null },
  [userId, 'account', 'subscription'],
  { baseUrl: 'https://api.example.com' }
);

const data = await user();
console.log(data.user_name); // reactive field — updates propagate to dependents

// Feeds: refetch on every read
const feed = createSource('/api/feed', null, [], { alwaysRefresh: true });

// Explicit refresh (pull-to-refresh, route enter)
await refreshSource(user);
await user(true); // same as refreshSource(user)

Object params support optional tokens with ? (for example /users/:userId?/account). Tokens are matched on the path only, so an absolute URL with a port (http://127.0.0.1:3003/api/users) is fine. Query params live in config.query only.

Refetches update fields on the same shell object rather than replacing it. Keys the server stops sending keep their last value.

Why the shape can't rename

A shell is writable, and a write sends the field straight back to the server. If a source were free to rename user_name to userName on the way in, the way out would PATCH a field the server has never heard of — and nothing would say so.

So the shape ties keys to the wire and transforms values only. A field's entry is read as:

  • a function — shorthand for get, applied on the way in: get(value, data, ctx)
  • an object whose keys are only get, set and/or default — a descriptor. set(value, shell, ctx) runs over the changes before they are sent
  • anything else — the field's default value, including a plain object that has any other key. { default: 'sm', options: ['sm', 'lg'] } and { get: '/read', put: '/write' } are data, not descriptors.

The default is what the shell holds until the server answers: display_name: 'Anonymous' gives you a string to render on first paint instead of undefined, and a key the server omits keeps its default rather than appearing as a hole. Use { default: … } when a field needs both a default and a codec, or when the default is itself an object made only of those three keys.

The shape is strict. Only declared keys land on the shell; other keys in the payload are ignored, and assigning an undeclared field throws with the list of declared ones. Commits are limited to declared fields for the same reason.

If you want userName in your UI, derive it where it is read — a computed is one-directional by construction, so there is nothing to keep in sync:

const userName = createComputed(() => user.user_name);

Pass a zero-argument thunk when it is more convenient to build the shape in code — createSource('/api/me', () => ({ ...baseFields, extra: null }), …). It is called once, during construction.

Parsing the response

config.parse turns the Response into the record the shape describes. It defaults to response.json():

// envelope: { data: { … }, meta: { … } }
createSource('/api/me', { name: null }, [], {
  parse: async (response) => (await response.json()).data,
});

// non-JSON
createSource('/api/readme', null, [], { parse: (response) => response.text() });

Omit the shape (pass null) and there is no shell: the source resolves to exactly what parse returned — an array, a Map, a Date, a class instance, a string. Such a source is read-only; commitSource rejects, because you can only write fields you have declared.

Lazy sync shell

getShell() returns a stable object immediately. Property reads are lazy:

  • First read of shell.user_name starts the GET (if not already in flight) and returns the current value (its default until hydrate).
  • When the response arrives, hydrate updates the same signals in place.
  • await user() still works and resolves to the same shell after fetch. If a GET for the same URL is already in flight, any read joins it rather than starting another — user(true) included, since what is on the wire is already as fresh as a refetch. When a param changes so the URL differs, the superseded request is aborted through its AbortSignal; a custom fetch must forward init.signal for that to take effect.
  • A failed fetch reports to config.onError(error, null, shell, ctx) on both the lazy and the awaited path; the awaited read also rejects, so callers can handle it inline. Without onError, a lazy failure logs to the console.

In stores, setup.source exposes this sync shell on store.<name> (not a Promise):

const useApp = defineStore((setup) => {
  const profile = setup.source('/api/me', { display_name: 'Anonymous' }, [], { fetch });
  return { profile };
});

const app = useApp();
console.log(app.profile.display_name); // 'Anonymous', then updates after fetch
app.profile.display_name = 'Ada';      // optimistic setter + PATCH

Reading store.<name> hands back the shell and starts the GET if one is needed — nothing is fetched until the property is touched, so a store can declare more sources than any one screen uses. $onChange fires with the source's key as fields hydrate.

A source declared without a shape has no shell, so store.<name> behaves like a binding and gives you the promise: const items = await app.feed.

The raw source getter returned from setup.source is still available for setup.commit(profile, …) and await profile() / refreshSource(profile).

When to refresh:

  • user() — cached read until params/query deps change
  • user(true) or refreshSource(user) — force this read (joins a same-URL request already in flight)
  • alwaysRefresh: true — fresh data on every user() read (feeds, dashboards)

Bindings support the same binding(true) / refreshBinding(binding) pattern. Only strict true forces a refresh; binding(false) is a normal read. Signal uses call args for set, not refresh.

Commit (writes)

Sources with a shape are writable by default (sync !== false). Writes use optimistic local updates, then PATCH the same URL (unless you override with commitHandler), then refetch to reconcile.

import { createSource, commitSource } from '@jucie.io/reactive';

const profile = createSource(
  '/api/me',
  { display_name: '' },
  [],
  {
    fetch: customFetch,
    fields: ['display_name'], // optional narrowing of the declared fields
    commitDebounce: 0,        // 0 = immediate; 300 default for setter-driven writes
    // commitHandler: async (changes, shell, ctx) => { ... }, // receives encoded changes
    // sync: false,             // local-only shell (no server writes)
  }
);

// Explicit write
await commitSource(profile, { display_name: 'Ada' });

// Setter-driven write (debounced by commitDebounce) — optimistic local update, background PATCH
const data = await profile();
data.display_name = 'Ada';
// In a store: app.profile.display_name = 'Ada' (sync shell, no await)

// Optional onError for setter-driven writes (called after rollback on failure)
onError: (error, changes, shell, ctx) => { ctx.error = error.message; },

Every value leaving for the server passes through its field's set first, so a shell that holds dollars can talk to a server that stores cents.

In stores: setup.commit(profile, { display_name: 'Ada' }) for explicit awaits; setter writes on store.profile are fire-and-forget unless you use onError.

Refetches hydrate the field signals directly, so server data does not re-trigger commits.

Binding with Previous Value

Access the previous computed value:

import { createSignal, createBinding } from '@jucie.io/reactive';

const count = createSignal(0);

const delta = createBinding(
  (ctx, prev) => {
    const current = count();
    return prev !== undefined ? current - prev : 0;
  },
  [count],
  { initialValue: 0 }
);

console.log(delta()); // 0
count(5);
console.log(delta()); // 5 (5 - 0)
count(8);
console.log(delta()); // 3 (8 - 5)

Store with Object Configuration

import { defineStore } from '@jucie.io/reactive';

const useApp = defineStore({
  state: {
    count: 0,
    message: 'Hello'
  },
  computed: {
    doubled(ctx) {
      return ctx.count * 2;
    }
  },
  actions: {
    increment() {
      this.count++;
    }
  },
  onInit(ctx) {
    console.log('Store initialized');
  },
  onDestroy(ctx) {
    console.log('Store destroyed');
  }
});

const app = useApp();

app.count // 0
app.message // Hello

Effects

import { createSignal, addEffect } from '@jucie.io/reactive';

const count = createSignal(0);

addEffect(count, (value) => {
  console.log('Effect:', value);
});

count(1); // Logs: "Effect: 1"

API Reference

Signals

  • createSignal(initialValue) - Create a reactive signal
  • signal() / signal.value - Read the value (tracks dependencies)
  • signal(value) / signal.value = value - Set the value as-is, functions included; no-op if unchanged
  • signal.update(fn) - Set the value to fn(current); returning undefined leaves it unchanged
  • destroySignal(signal) - Destroy a signal
  • isSignal(value) - Check if value is a signal
  • isDirtySignal(signal) - Check if signal needs recomputation

Computed

  • createComputed(fn, config) - Create a computed value with auto-tracked dependencies
  • computed() / computed.value - Read the value; assigning .value throws
  • destroyComputed(computed) - Destroy a computed
  • isComputed(value) - Check if value is a computed
  • isDirtyComputed(computed) - Check if computed needs recomputation

Bindings

  • createBinding(fn, dependencies, config) - Create a binding with explicit dependencies
  • destroyBinding(binding) - Destroy a binding
  • isBinding(value) - Check if value is a binding
  • isDirtyBinding(binding) - Check if binding needs recomputation

Stores

  • defineStore(setupFn|config, options) - Create a reactive store
  • Store setup factories: signal, computed, binding, source, action, extend
  • Object config keys: state, computed, sources, actions, extend, onInit, onDestroy
  • isStore(value) - Check if value is a store
  • refreshStore(store) - Refresh a store

Subscribers

  • createSubscriber(getter, callback, config) - Create a subscriber
  • destroySubscriber(subscriber) - Destroy a subscriber
  • isSubscriber(value) - Check if value is a subscriber

Reactive System

  • addEffect(getter, callback) - Add effect to reactive value
  • removeEffect(getter, callback) - Remove effect
  • provideContext(value) - Provide context
  • getContext() - Get current context

Configuration Options

Signal

{
  debounce: 100,        // Debounce updates (ms)
  immediate: true,      // Compute immediately
  effects: [fn],        // Initial effects
  detached: false,     // Skip dependency tracking
  onAccess: (value) => {} // Callback on access
}

Binding/Computed Config

{
  debounce: 100,        // Debounce updates (ms)
  immediate: true,      // Compute immediately
  effects: [fn],        // Initial effects
  detached: false,     // Skip dependency tracking
  onAccess: (value) => {}, // Callback on access
  context: () => ctx,   // Custom context provider
  initialValue: value   // Initial cached value
}

Store Options

{
  mode: 'development'  // Enable dev features
}

License and Usage

This software is provided under the MIT License with Commons Clause.

✅ What You Can Do

  • Use this library freely in personal or commercial projects
  • Include it in your paid products and applications
  • Modify and fork for your own use
  • View and learn from the source code

❌ What You Cannot Do

  • Sell this library as a standalone product or competing state management solution
  • Offer it as a paid service (SaaS) where the primary value is this library
  • Create a commercial fork that competes with this project

⚠️ No Warranty or Support

This software is provided "as-is" without any warranty, support, or guarantees:

  • No obligation to provide support or answer questions
  • No obligation to accept or implement feature requests
  • No obligation to review or merge pull requests
  • No obligation to fix bugs or security issues
  • No obligation to maintain or update the software

You are welcome to submit issues and pull requests, but there is no expectation they will be addressed. Use this software at your own risk.

See the LICENSE file for complete terms.

Contributing

While contributions are welcome, please understand there is no obligation to review, accept, or merge them. This project is maintained at the discretion of the author.


Made with ⚡ by Adrian Miller