@jucie.io/reactive
v1.1.4
Published
Fine-grained reactivity with signals, computed values and effects, plus optional Vue and React entry points
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, sync or async
- Sources: Remote data you can read straight away — defaults first, the response when it lands, optimistic writes back
- Stores: Component-like reactive contexts with state management
- Subscribers: Side effects that run when reactive values change
- Framework bridges: Vue, React and Preact track any reactive read — import one entry and plain stores just work
- 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, computeds and sources are unwrapped. Read them as properties (
counter.count,counter.doubled) instead of calling them. - Imports, not injected helpers: build reactives with
createSignal,createComputed,createSourceandcreateBinding, and read them through closures. Setup receives only(extend, destroy); plugins come fromusePlugin(name). Only what setup returns is public. - Actions (
this): Returned functions are bound to the store. Usethisto read and write returned state; caller arguments map straight to parameters.
import { defineStore, createSignal, createComputed } from '@jucie.io/reactive';
const useCounter = defineStore(() => {
const count = createSignal(0);
const updateCount = createSignal(0); // private — not returned, so not on the store
const doubled = createComputed(() => count() * 2);
function increment() {
updateCount.update(prev => prev + 1); // private signals stay available via closure
this.count++; // returned state is available on this
}
return { count, doubled, increment };
});
const counter = useCounter();
counter.count; // 0 — a property, not counter.count()
counter.increment();
counter.doubled; // 2
counter.count = 10; // assigning a signal's key writes the signalextend() returns another store's surface, so read it by closure too:
const useLabelled = defineStore((extend) => {
const counter = extend(useCounter);
return { label: createComputed(() => `Count: ${counter.count}`) };
});Plugins are imported, not injected:
import { usePlugin } from '@jucie.io/reactive';
const router = usePlugin('router'); // throws if no plugin is registered under that nameA store can also be written as an object — see Store with Object Configuration.
Framework integration
Stores and reactives are framework-agnostic. To render them, import the entry for your framework instead of @jucie.io/reactive — it re-exports the whole core API and installs a bridge first:
| Entry | Adds | In a component |
|---|---|---|
| @jucie.io/reactive/vue | bridgeRef, installVueBridge | Nothing — any reactive read in a render or computed is tracked |
| @jucie.io/reactive/react | observer, useReactive, installReactBridge | Wrap the component: observer(Component) |
| @jucie.io/reactive/preact | observer, useReactive, installPreactBridge | Wrap the component: observer(Component) — preact/hooks only, no compat |
// stores/counter.js — the same defineStore as core
import { defineStore, createSignal } from '@jucie.io/reactive/vue'; // or /react, /preact
export const useCounter = defineStore(() => ({ count: createSignal(0) }));The bridge tracks any reactive read, not just store properties: a helper that reads a signal, a module-level signal, a computed from another store. There is no framework-specific kind of store.
vue, react and preact are optional peer dependencies; install the one you use. The framework entries are listed under sideEffects in package.json, so a bare import '@jucie.io/reactive/vue' survives tree-shaking.
Subscribers
Side effects for reactive values:
import { createSignal, createSubscriber } from '@jucie.io/reactive';
const count = createSignal(0);
const unsubscribe = createSubscriber(
() => count(),
(value) => console.log('Count changed:', value)
);
count(1); // Logs: "Count changed: 1"
unsubscribe();Effects are batched and run on a setTimeout(0). Pass { immediate: true } to also run the effect once straight away.
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 — the 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 with async functions; reading one returns a promise:
const userId = createSignal(1);
const userData = createBinding(
async () => (await fetch(`/api/users/${userId()}`)).json(),
[userId]
);
const data = await userData();
await userData(true); // force a recompute; refreshBinding(userData) does the sameFor remote data you want to render, prefer a Source: it is readable synchronously, so components can bind it.
Sources
A source is a reactive over a remote value that you can read straight away. The first read returns the shape's defaults and starts the fetch; when the response lands it replaces the value, and everything that read the source updates.
import { createSignal, createSource, resolveSource } from '@jucie.io/reactive';
const userId = createSignal(1);
const user = createSource(
'/api/users/:userId',
{ id: null, display_name: 'Anonymous' }, // shape: field -> default
{ userId }, // params
{ baseUrl: 'https://api.example.com' } // config
);
user(); // { id: null, display_name: 'Anonymous' }, and the GET starts
user.display_name; // each shape field is also a property on the source
await resolveSource(user);
user.display_name; // 'Ada'In a store, a returned source is a value on the surface, like a signal:
const useApp = defineStore(() => {
const profile = createSource('/api/me', { display_name: 'Anonymous' });
return { profile };
});
useApp().profile.display_name; // 'Anonymous', then the server's valueNothing is fetched until the source is first read, so a store can declare more sources than any one screen uses.
Shape
The shape's keys are the server's field names. A shape transforms values, never keys, so a write can send a field straight back. Each field is one of:
- a descriptor — an object made only of
default,getand/orset:defaultis what the field holds before the server answers. It is used as written, not passed throughget.get(value, response)transforms the value on the way in; the second argument is the whole parsed response, so a field can be built from its siblings.set(value, changes)transforms it on the way out, when you write.
- a function — shorthand for
get, with no default. - anything else — the default, with no transforms.
{ default: 'sm', options: ['sm', 'lg'] }is data, not a descriptor, because it has another key.
const product = createSource('/api/products/:id', {
name: '',
sku: (value) => value.toUpperCase(),
price: { default: 0, get: (cents) => cents / 100, set: (dollars) => dollars * 100 },
}, { id });The shape is strict: keys the server sends that the shape doesn't declare are dropped. A fetch answers with the whole record, so a field the response leaves out goes back to its default — after a param change, the previous record's value would belong to a different record. get only ever sees a value the response actually sent.
Pass a zero-argument function to build the shape in code: createSource(url, () => ({ ...base, extra: null })).
No shape (null): the source is exactly what parse returned — an array, a string, anything. It starts as undefined.
Params, URLs and refetching
- Array params append path segments:
createSource('/users', null, [userId, 'posts'])→/users/7/posts. - Object params fill
:tokens;:token?is optional:/users/:userId?/account. Tokens are matched on the path only, sohttp://127.0.0.1:3003/apiis fine. Arrays can't be mixed with tokens. config.querybuilds the query string; empty values are skipped.config.baseUrl,headers,auth(sent asAuthorization),bodyandmethodshape the request.
Any of these may be a signal or computed. When one changes, the source refetches if it has been read — without telling its readers anything, since the value they hold is still current. They update when the response lands. Several changes in one tick make one request, built with the latest values.
Requests are de-duplicated: the same request already in flight is joined, and a request identical to the one the current value came from isn't sent again. A request replaced by a newer one is aborted, and its response is ignored even if it arrives last. A custom fetch must forward init.signal for the abort to stop the network request.
Waiting for data: resolveSource and refreshSource
const data = await resolveSource(user); // one source -> its value
const [u, posts] = await resolveSource([user, userPosts]); // an array -> an array
const fresh = await refreshSource(user); // force a fetchresolveSource gives the current value once nothing is pending. It starts a source that has never been read, waits for a request or write in flight (following a request that replaces it), and retries a source whose last request failed. It rejects if the request it waits on fails. Use it where code has to know — a route guard, a test.
refreshSource fetches again even if a value is held; a request already queued or in flight is joined instead.
Writes
A write is optimistic: readers see it at once, and it is committed to the server behind them.
user({ display_name: 'Ada' }); // a partial of the shape's fields
user((prev) => ({ display_name: prev.display_name.trim() }));
user.display_name = 'Ada'; // the same, one field
useApp().profile = { display_name: 'Ada' }; // the same, through a store
await resolveSource(user({ display_name: 'Ada' })); // a write returns the source, so this waits for it- Only the shape's fields are applied and sent; each goes through its
set. A shaped source must be written with an object; a shapeless one merges objects and replaces anything else. - The commit is a
PATCHto the source's URL (config.commitMethodchanges the verb), orconfig.commitHandler(payload)if you provide one — for examplecommitHandler: async () => nullfor a source you only ever update locally. - If the response is the record — a plain object carrying at least one declared field — it wins. Fields it leaves out keep their current value, since a write response is often partial.
{ ok: true }changes nothing. - On failure only this write's fields roll back, and only where they still hold what it wrote, so a later write survives an earlier one failing.
onError(error, changes)is called andresolveSourcerejects. - A write drops a fetch still in flight — its answer predates the write — and fetches again once the writes settle.
store.user.name = 'x' writes nothing: the value is a plain object. Write through the source or assign a partial to the store key.
Errors
A non-ok response throws an HttpError (with the Response on .response). A shaped source whose response isn't an object, and a request that can't be built (a missing required param), fail too. Every failure goes to config.onError — (error) for a fetch, (error, changes) for a write — which by default logs with console.error. The value stays as it was and readers aren't notified. An abort is never reported.
A read never retries a failed source (a failing endpoint would be hit on every render); resolveSource and a changed input do.
Config
| Option | Default | |
|---|---|---|
| fetch | global fetch | (url, init); must return a Response-like object |
| parse | response.json() | Turns the response into the data the shape is applied to |
| baseUrl, query, headers, auth, body, method | — | The request; any may be reactive |
| onError | console.error | (error) for a fetch, (error, changes) for a write |
| commitMethod | 'PATCH' | The verb writes use |
| commitHandler | — | (payload) => result; replaces the write request entirely |
destroySource(source) aborts anything in flight and takes the source out of the graph.
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
An object has no closure to read from, so its computeds and hooks receive the store surface as ctx:
import { defineStore } from '@jucie.io/reactive';
const useApp = defineStore({
state: {
count: 0,
message: 'Hello'
},
computed: {
doubled(ctx) {
return ctx.count * 2;
}
},
sources: {
feed: { url: '/api/feed', shape: { title: '', body: '' } }
},
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'
app.feed // { title: '', body: '' }, then the responseEach sources entry is { url, shape, params, ...config }. Source callbacks (parse, get, onError) don't receive ctx.
Effects
import { createSignal, addEffect } from '@jucie.io/reactive';
const count = createSignal(0);
addEffect(count, (value) => {
console.log('Effect:', value);
});
count(1); // Logs: "Effect: 1"Bridges
The Vue, React and Preact entries are built on one core hook, which you can use to connect another UI layer:
import { Reactive } from '@jucie.io/reactive';
const remove = Reactive.addBridge('my-framework', {
track(reactive) { /* a reactive was read, outside any reactive computation */ },
trigger(reactive) { /* a reactive changed; its readers should update */ },
});track is called for every top-level read — reads made while a computed computes belong to that computed, so a framework depends on the outermost reactive it read. trigger is called when a reactive tells its readers it changed, after everything downstream is marked dirty; a source's param change doesn't trigger, only its response or a write. Installing the same name again replaces the bridge.
API Reference
Signals
createSignal(initialValue, config)- 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),isSignal(value),isDirtySignal(signal)
Computed
createComputed(fn, config)- Create a computed value with auto-tracked dependenciescomputed()/computed.value- Read the value; assigning.valuethrowsdestroyComputed(computed),isComputed(value),isDirtyComputed(computed)
Bindings
createBinding(fn, dependencies, config)- Create a binding with explicit dependenciesbinding(true)/refreshBinding(binding)- Force a recomputedestroyBinding(binding),isBinding(value),isDirtyBinding(binding)
Sources
createSource(url, shape, params, config)- Create a sourcesource()/source.field- Read the value (starts the first fetch)source(changes)/source(prev => changes)/source.field = value- Write; returns the sourceresolveSource(source | sources[])- The value once nothing is pendingrefreshSource(source | sources[])- Fetch againdestroySource(source),isSource(value),isDirtySource(source)HttpError- Thrown for a non-ok response;.responseis theResponse
Stores
defineStore(setupFn | config, options)/defineStore(name, setupFn, options)- Create a store; returnsuseStore()- Setup signature:
(extend, destroy) => ({ ...returned }) - Object config keys:
state,computed,sources,actions,extend,onInit,onDestroy injectStore(name)- Resolve a named storerefreshStore(useStore),rebuildStore(useStore, setup),destroyStore(useStore)definePlugin(name, setupFn),usePlugin(name)isStore(useStore)
Subscribers and Effects
createSubscriber(getterOrFn, effect, config)- Runeffect(value)when it changes; returns an unsubscribeaddEffect(getter, effect)/removeEffect(getter, effect)onDirty(getter, listener)/removeOnDirty(getter, listener)- Synchronous notification when a reactive is marked dirty
Reactive System
destroy(getter)- Tear down any reactive or store, whatever it isgetterType(getter)-'signal','computed','binding','source','store'ornullReactive.from(getter)/Store.from(useStore)- The instance behind a getter, ornullReactive.addBridge(name, { track, trigger })- Connect a UI framework (see Bridges)isReactive(value),isDirty(getter),markAsDirty(getter),forceRecompute(getter)addContext(key, value),useContext(...keys),hasContext(...keys)- Global context, the defaultcontextfor computeds and bindings
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 (the fn's first argument)
initialValue: value // Initial cached value
}Source Config
See Sources → Config.
Store Options
{
id: 'counter', // Same as the named form: defineStore('counter', setup)
mode: 'development' // Object config: use a state field's `dummy` value
}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
