@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
Maintainers
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/reactiveQuick 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); // 10Calling 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 itUse 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 .valueSetting 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-onlyStores
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, thectxparameter 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. Usethisto 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); // 2Framework 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,setand/ordefault— 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_namestarts 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 itsAbortSignal; a customfetchmust forwardinit.signalfor 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. WithoutonError, 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 + PATCHReading 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 changeuser(true)orrefreshSource(user)— force this read (joins a same-URL request already in flight)alwaysRefresh: true— fresh data on everyuser()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 signalsignal()/signal.value- Read the value (tracks dependencies)signal(value)/signal.value = value- Set the value as-is, functions included; no-op if unchangedsignal.update(fn)- Set the value tofn(current); returningundefinedleaves it unchangeddestroySignal(signal)- Destroy a signalisSignal(value)- Check if value is a signalisDirtySignal(signal)- Check if signal needs recomputation
Computed
createComputed(fn, config)- Create a computed value with auto-tracked dependenciescomputed()/computed.value- Read the value; assigning.valuethrowsdestroyComputed(computed)- Destroy a computedisComputed(value)- Check if value is a computedisDirtyComputed(computed)- Check if computed needs recomputation
Bindings
createBinding(fn, dependencies, config)- Create a binding with explicit dependenciesdestroyBinding(binding)- Destroy a bindingisBinding(value)- Check if value is a bindingisDirtyBinding(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 storerefreshStore(store)- Refresh a store
Subscribers
createSubscriber(getter, callback, config)- Create a subscriberdestroySubscriber(subscriber)- Destroy a subscriberisSubscriber(value)- Check if value is a subscriber
Reactive System
addEffect(getter, callback)- Add effect to reactive valueremoveEffect(getter, callback)- Remove effectprovideContext(value)- Provide contextgetContext()- 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
