Reactive plugin for @jucie-state/core - signals, computed values, and fine-grained reactivity
npm install @jucie-state/reactiveReactive programming plugin for @jucie-state/core providing signals, computed values (reactors), and declarative reactive surfaces with fine-grained dependency tracking.
- 🎯 Fine-Grained Reactivity: Automatic dependency tracking at the property level
- ⚡ Signals: Simple reactive values with getter/setter interface
- 🧮 Computed Values (Reactors): Derived values that automatically update
- 🏄 Surfaces: Component-like reactive contexts with lifecycle management
- 🔄 Async Support: Built-in support for async computations
- 📦 Batched Updates: Efficient batch processing of changes
- 🎬 Effects: Run side effects when reactive values change
- 🔌 State Integration: Seamless integration with @jucie-state/core
- 🎨 Framework Adapters: Built-in React adapter support
``bash`
npm install @jucie-state/reactive
Note: Requires @jucie-state/core as a peer dependency.
Simple reactive values:
`javascript
import { createSignal } from '@jucie-state/reactive';
// Create a signal
const count = createSignal(0);
console.log(count()); // 0
// Update the signal
count(1);
console.log(count()); // 1
// Update based on current value
count(n => n + 1);
console.log(count()); // 2
`
Automatically computed values that track dependencies:
`javascript
import { createSignal, createReactor } from '@jucie-state/reactive';
const firstName = createSignal('John');
const lastName = createSignal('Doe');
// Create a computed value
const fullName = createReactor(() => {
return ${firstName()} ${lastName()};
});
console.log(fullName()); // "John Doe"
// Update dependencies - fullName automatically recomputes
firstName('Jane');
console.log(fullName()); // "Jane Doe"
`
`javascript
import { State } from '@jucie-state/core';
import { Reactive, createReactor } from '@jucie-state/reactive';
const state = new State({
user: { name: 'Alice', age: 30 }
});
state.install(Reactive);
// Create a reactor that depends on state
const userInfo = createReactor(() => {
const user = state.get(['user']);
return ${user.name} is ${user.age} years old;
});
console.log(userInfo()); // "Alice is 30 years old"
// Update state - reactor automatically recomputes
state.set(['user', 'age'], 31);
console.log(userInfo()); // "Alice is 31 years old"
`
#### createSignal(initialValue, config?)
Create a reactive signal.
`javascript`
const count = createSignal(0);
const name = createSignal('Alice', {
debounce: 100, // Debounce updates by 100ms
effects: [() => console.log('Name changed!')],
immediate: true // Run effects immediately
});
Parameters:
- initialValue (any): Initial valueconfig
- (object): Optional configurationdebounce
- (number): Debounce time in mseffects
- (Function[]): Side effect functionsimmediate
- (boolean): Compute immediatelyonAccess
- (Function): Called when value is accesseddetatched
- (boolean): Don't track as dependency
Returns: Signal getter/setter function
Usage:
`javascript
// Get value
const value = signal();
// Set value
signal(newValue);
// Update based on current value
signal(current => current + 1);
`
#### createReactor(fn, config?)
Create a computed reactive value.
`javascript
const doubled = createReactor(() => count() * 2);
const asyncData = createReactor(async () => {
const response = await fetch('/api/data');
return response.json();
});
`
Parameters:
- fn (Function): Computation functionconfig
- (object): Optional configurationdebounce
- (number): Debounce recomputationeffects
- (Function[]): Side effect functionsimmediate
- (boolean): Compute immediatelyinitialValue
- (any): Initial cached valuecontext
- (any): Context passed to functiondetatched
- (boolean): Don't track as dependency
Returns: Reactor getter function
Async Support:
`javascript
const data = createReactor(async () => {
const result = await fetchData();
return result;
});
// Async reactors return promises
data().then(value => console.log(value));
`
#### destroyReactor(reactor)
Cleanup and destroy a reactor.
`javascript`
const computed = createReactor(() => value() * 2);
destroyReactor(computed);
#### addEffect(getter, effect)
Add a side effect to a reactive value.
`javascript
const count = createSignal(0);
addEffect(count, (value) => {
console.log('Count changed to:', value);
});
count(1); // Console: "Count changed to: 1"
`
Composable reactive contexts that combine state, computed values, and actions:
#### defineSurface(setupFn)
Create a reactive surface - a self-contained reactive unit similar to a component.
`javascript
import { defineSurface } from '@jucie-state/reactive';
const useCounter = defineSurface((setup) => {
// Create reactive value (signal)
const count = setup.value(0);
// Create computed value
const doubled = setup.computed(() => count() * 2);
// Create action
const increment = setup.action(() => {
count(count() + 1);
});
// Return what should be exposed
return {
count,
doubled,
increment
};
});
// Use the surface - returns a reactor
const counter = useCounter();
// The reactor returns current snapshot with computed values
console.log(counter.count); // 0 (current value, not a function)
console.log(counter.doubled); // 0
// Actions are directly callable
counter.increment();
console.log(counter.count); // 1
console.log(counter.doubled); // 2
`
#### Setup Function API
The setup function receives an object with these methods:
Creating Reactive Values:
- setup.value(initial) - Create a signal (reactive primitive)setup.state(initial)
- - Create a state instance (reactive object/array)setup.computed(fn)
- - Create a computed valuesetup.action(fn)
- - Create an action function
Composition:
- setup.extend(surface) - Extend another surface to inherit its values/actionssetup.destroy()
- - Manually destroy the surface
Usage Example:
`javascript${u.name} (${u.age})
const useUser = defineSurface((setup) => {
const user = setup.value({ name: 'Alice', age: 30 });
const displayName = setup.computed(() => {
const u = user();
return ;`
});
const setName = setup.action((_, name) => {
user(u => ({ ...u, name }));
});
return { user, displayName, setName };
});
#### Surface Return Value
The surface reactor, when called, returns a frozen snapshot with:
- Values rendered: Signals, state, and computed values show their current values (not functions)
- Actions available: Action functions are directly callable
- Helper methods: Special $ prefixed methods for advanced operations
#### Surface Helper Methods
The returned surface includes these helper methods:
Value Access:
- surface.$get(path) - Get value at a path (e.g., ['user', 'name'])surface.$snapshot(path?)
- - Get frozen snapshot at path
Value Updates:
- surface.$setValue(name, value) - Set a signal value by namesurface.$setState(path, value)
- - Set a state value at nested pathsurface.$set(path, value)
- - Generic set for signals or statesurface.$dispatch(name, ...args)
- - Call an action by name
Subscriptions:
- surface.$subscribe(listener) - Subscribe to any surface changessurface.$bind(path)
- - Returns [getSnapshot, subscribe] for a specific pathsurface.$adapter()
- - Returns [getSnapshot, subscribe] for framework integration
Utilities:
- surface.$inject(overrides) - Create new surface with injected overridessurface.$destroy()
- - Destroy the surface and cleanup
Example:
`javascript
const counter = useCounter();
// Get nested value
counter.$get(['count']); // 0
// Set signal value
counter.$setValue('count', 5);
// Dispatch action
counter.$dispatch('increment');
// Subscribe to changes
const unsubscribe = counter.$subscribe((snapshot) => {
console.log('Counter changed:', snapshot.count);
});
// Bind to specific path for framework integration
const [getCount, subscribe] = counter.$bind(['count']);
console.log(getCount()); // Current count value
`
Computed functions and actions receive the surface context as the first parameter:
`javascript`
const useCalculator = defineSurface((setup) => {
const num1 = setup.value(5);
const num2 = setup.value(10);
// Context gives access to all surface values
const sum = setup.computed((context) => {
return context.num1() + context.num2();
});
// Actions also receive context
const updateNum1 = setup.action((context, newValue) => {
context.num1(newValue);
});
return { num1, num2, sum, updateNum1 };
});
Surfaces can extend other surfaces to compose functionality:
`javascript
const useCounter = defineSurface((setup) => {
const count = setup.value(0);
const increment = setup.action(() => count(count() + 1));
return { count, increment };
});
const useEnhancedCounter = defineSurface((setup) => {
// Extend base counter - returns its context
const counter = setup.extend(useCounter);
// Add new functionality
const incrementTwice = setup.action(() => {
counter.increment();
counter.increment();
});
return { incrementTwice };
});
// The enhanced surface has both original and new functionality
const enhanced = useEnhancedCounter();
console.log(enhanced.count); // 0 (from extended surface)
enhanced.increment(); // Available from extension
enhanced.incrementTwice(); // New functionality
console.log(enhanced.count); // 2
`
#### React Integration with $adapter()
`javascript
const useCounter = defineSurface((setup) => {
const count = setup.value(0);
const increment = setup.action(() => count(count() + 1));
return { count, increment };
});
// In React component
function CounterComponent() {
const counter = useCounter();
const [snapshot, subscribe] = counter.$adapter();
// Use with React.useSyncExternalStore
const state = React.useSyncExternalStore(subscribe, snapshot);
return (
Count: {state.count}
#### Path Binding with
$bind()`javascript
const useUser = defineSurface((setup) => {
const user = setup.value({
profile: { name: 'Alice', email: 'alice@example.com' }
});
return { user };
});const app = useUser();
// Bind to nested path
const [getName, subscribe] = app.$bind(['user', 'profile', 'name']);
console.log(getName()); // 'Alice'
const unsubscribe = subscribe((name) => {
console.log('Name changed to:', name);
});
`Advanced Usage
$3
`javascript
const searchTerm = createSignal('');const searchResults = createReactor(async () => {
const term = searchTerm();
if (!term) return [];
const response = await fetch(
/api/search?q=${term});
return response.json();
}, {
debounce: 300 // Wait 300ms after last change
});
`$3
`javascript
const showDetails = createSignal(false);
const details = createSignal({ name: 'Alice' });const display = createReactor(() => {
if (showDetails()) {
return details().name; // Only depends on details when showDetails is true
}
return 'Hidden';
});
`$3
`javascript
const component = createReactor((context) => {
return User: ${context.userName};
}, {
context: { userName: 'Alice' }
});
`$3
`javascript
const count = createSignal(0, {
effects: [
(value) => console.log('Effect 1:', value),
(value) => console.log('Effect 2:', value),
]
});// Or add effects later
addEffect(count, (value) => {
console.log('Effect 3:', value);
});
`$3
`javascript
const useTodoList = defineSurface((setup) => {
// State
const todos = setup.value([]);
const filter = setup.value('all');
// Computed
const filteredTodos = setup.computed((context) => {
const allTodos = context.todos();
const currentFilter = context.filter();
if (currentFilter === 'active') {
return allTodos.filter(t => !t.done);
}
if (currentFilter === 'completed') {
return allTodos.filter(t => t.done);
}
return allTodos;
});
const stats = setup.computed((context) => {
const allTodos = context.todos();
return {
total: allTodos.length,
active: allTodos.filter(t => !t.done).length,
completed: allTodos.filter(t => t.done).length
};
});
// Actions
const addTodo = setup.action((context, text) => {
context.todos(current => [...current, { text, done: false, id: Date.now() }]);
});
const toggleTodo = setup.action((context, id) => {
context.todos(current =>
current.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
});
const setFilter = setup.action((context, filterValue) => {
context.filter(filterValue);
});
return {
todos,
filter,
filteredTodos,
stats,
addTodo,
toggleTodo,
setFilter
};
});// Use it
const todoList = useTodoList();
console.log(todoList.stats); // { total: 0, active: 0, completed: 0 }
todoList.addTodo('Learn surfaces');
console.log(todoList.stats); // { total: 1, active: 1, completed: 0 }
`Performance Tips
1. Use signals for primitive values - They're optimized for simple get/set
2. Debounce expensive computations - Prevent excessive recomputation
3. Use
detatched: true for values you read but don't want to track
4. Batch updates when making multiple changes
5. Destroy reactors when no longer needed to prevent memory leaksCommon Patterns with Surfaces
$3
`javascript
// Create reusable surfaces
const useCounter = defineSurface((setup) => {
const count = setup.value(0);
const increment = setup.action(() => count(count() + 1));
return { count, increment };
});const useTodos = defineSurface((setup) => {
const todos = setup.value([]);
const addTodo = setup.action((_, todo) => {
todos(current => [...current, todo]);
});
return { todos, addTodo };
});
// Combine them
const useApp = defineSurface((setup) => {
const counter = setup.extend(useCounter);
const todoContext = setup.extend(useTodos);
const addTodoAndIncrement = setup.action((context, todo) => {
todoContext.addTodo(todo);
counter.increment();
});
return { addTodoAndIncrement };
});
const app = useApp();
console.log(app.count); // 0 (from counter)
console.log(app.todos); // [] (from todos)
app.addTodoAndIncrement('Task 1');
console.log(app.count); // 1
console.log(app.todos); // ['Task 1']
`$3
`javascript
const useStore = defineSurface((setup) => {
// Create a State instance (reactive object)
const store = setup.state({
user: {
profile: { name: 'Alice', age: 30 }
}
});
const updateName = setup.action((_, name) => {
store.set(['user', 'profile', 'name'], name);
});
return { store, updateName };
});const app = useStore();
// On the surface, store is rendered as its current value
console.log(app.store); // { user: { profile: { name: 'Alice', age: 30 } } }
console.log(app.store.user.profile.name); // 'Alice'
// Update using helper or action
app.$setState(['store', 'user', 'profile', 'name'], 'Bob');
console.log(app.store.user.profile.name); // 'Bob'
`$3
`javascript
const useForm = defineSurface((setup) => {
const name = setup.value('');
const email = setup.value('');
const errors = setup.value({});
const isValid = setup.computed((context) => {
const currentName = context.name();
const currentEmail = context.email();
return currentName.length > 0 && currentEmail.includes('@');
});
const submit = setup.action(async (context) => {
if (context.isValid()) {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({
name: context.name(),
email: context.email()
})
});
}
});
return { name, email, errors, isValid, submit };
});// Use it
const form = useForm();
form.$setValue('name', 'Alice');
form.$setValue('email', 'alice@example.com');
console.log(form.isValid); // true
await form.submit();
``See the root LICENSE file for license information.
- @jucie-state/core - Core state management system
- @jucie-state/history - Undo/redo functionality
- @jucie-state/matcher - Path pattern matching
- @jucie-state/on-change - Change listeners