@jucie-state/reactive
v1.0.9
Published
Reactive plugin for @jucie-state/core - signals, computed values, and fine-grained reactivity
Maintainers
Readme
@jucie-state/reactive
Reactive programming plugin for @jucie-state/core providing signals, computed values (reactors), and declarative reactive surfaces with fine-grained dependency tracking.
Features
- 🎯 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
Installation
npm install @jucie-state/reactiveNote: Requires @jucie-state/core as a peer dependency.
Quick Start
Signals
Simple reactive values:
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()); // 2Reactors (Computed Values)
Automatically computed values that track dependencies:
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"Integration with State
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"API Reference
Signals
createSignal(initialValue, config?)
Create a reactive signal.
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:
// Get value
const value = signal();
// Set value
signal(newValue);
// Update based on current value
signal(current => current + 1);Reactors (Computed)
createReactor(fn, config?)
Create a computed reactive value.
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:
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.
const computed = createReactor(() => value() * 2);
destroyReactor(computed);Effects
addEffect(getter, effect)
Add a side effect to a reactive value.
const count = createSignal(0);
addEffect(count, (value) => {
console.log('Count changed to:', value);
});
count(1); // Console: "Count changed to: 1"Surfaces
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.
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); // 2Setup 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:
const useUser = defineSurface((setup) => {
const user = setup.value({ name: 'Alice', age: 30 });
const displayName = setup.computed(() => {
const u = user();
return `${u.name} (${u.age})`;
});
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:
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 valueContext in Computed and Actions
Computed functions and actions receive the surface context as the first parameter:
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 };
});Extending Surfaces
Surfaces can extend other surfaces to compose functionality:
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); // 2Framework Integration
React Integration with $adapter()
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 (
<div>
<p>Count: {state.count}</p>
<button onClick={state.increment}>Increment</button>
</div>
);
}Path Binding with $bind()
const useUser = defineSurface((setup) => {
const user = setup.value({
profile: { name: 'Alice', email: '[email protected]' }
});
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
Debounced Reactors
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
});Conditional Dependencies
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';
});Context Passing
const component = createReactor((context) => {
return `User: ${context.userName}`;
}, {
context: { userName: 'Alice' }
});Multiple Effects
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);
});Surface with Complex State
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
- Use signals for primitive values - They're optimized for simple get/set
- Debounce expensive computations - Prevent excessive recomputation
- Use
detatched: truefor values you read but don't want to track - Batch updates when making multiple changes
- Destroy reactors when no longer needed to prevent memory leaks
Common Patterns with Surfaces
Composing Multiple Surfaces
// 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']Using State within Surfaces
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'Form State Management
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', '[email protected]');
console.log(form.isValid); // true
await form.submit();License
See the root LICENSE file for license information.
Related Packages
- @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
