Reactivity With Signals
The HellaJS core package is a reactive system that automatically tracks dependencies between data and computations. This lets you build applications where changes in state automatically propagate, eliminating the need for manual subscriptions.
Reactive Primitives
Three core primitives form the foundation of reactivity.
Signals - Reactive State
Reactive containers that notify dependent computations when their value changes.
Check out the signal API Reference.
const count = signal(0);
// Reading values (getter)
console.log(count()); // 0
// Setting values (setter) - triggers dependency updates
count(5);
// Update value
console.log(count()); // 5
Computed - Derived Values
Derived values that automatically recompute when their dependencies change.
Computed functions are lazily evaluated and cached, only recalculating when accessed AND their dependencies have changed.
Check out the computed API Reference.
const count = signal(0);
const doubled = computed(() => count() * 2);
console.log(doubled()); // 0
count(5);
console.log(doubled()); // 10 - automatically recalculated
Effects - Reactive Side Effects
Side effects that run immediately when reactive dependencies change. Unlike computed values, effects run eagerly.
Check out the effect API Reference.
const count = signal(0);
const doubled = computed(() => count() * 2);
// Effect automatically tracks dependencies
effect(() => {
console.log(`Count: ${count()}, Doubled: ${doubled()}`);
});
count(5); // Logs: "Count: 5, Doubled: 10"
Working with Data
Immutable Updates
Signals detect changes using reference equality. You must create new objects or arrays rather than mutating existing ones.
const todos = signal([
{ id: 1, text: 'Learn HellaJS', done: false },
{ id: 2, text: 'Build an app', done: false }
]);
// ❌ Mutation doesn't trigger reactivity
todos().push({ id: 3, text: 'New todo', done: false }); // No update
todos()[0].done = true; // No update
// ✅ Immutable updates trigger reactivity
todos([...todos(), { id: 3, text: 'New todo', done: false }]); // Creates new array
Async Operations in Effects
Effects should handle asynchronous operations manually. The effect function itself should not be async, but can contain async operations.
const todoId = signal(1);
const todoDetails = signal(null);
const loading = signal(false);
effect(() => {
const id = todoId();
loading(true);
fetch(`/api/todos/${id}`)
.then(response => response.json())
.then(todo => {
todoDetails(todo);
loading(false);
})
.catch(error => {
console.error('Failed to load todo:', error);
todoDetails(null);
loading(false);
});
});
// Changing todoId automatically triggers new API call
todoId(2);
Conditional Dependencies
Conditional logic in computed values and effects determines which signals become dependencies.
const view = signal('counter');
const count = signal(0);
const todos = signal([]);
const currentTitle = computed(() => {
const currentView = view();
// Only the relevant signal becomes a dependency
if (currentView === 'counter') {
return `Count: ${count()}`; // Only tracks count
} else {
return `Todos: ${todos().length}`; // Only tracks todos
}
});
todos([...todos(), { id: 1, text: 'New todo', done: false }]); // currentTitle doesn't update
view('todos'); // currentTitle updates and now tracks todos
Transformation Chains
Computed values create transformation pipelines that efficiently propagate changes through complex data flows.
const todos = signal([
{ id: 1, text: 'Learn HellaJS', done: false },
{ id: 2, text: 'Build an app', done: false },
{ id: 3, text: 'Write docs', done: true }
]);
const activeTodos = computed(() => todos().filter(t => !t.done));
const todoTotals = computed(() => ({
total: todos().length,
active: activeTodos().length,
}));
console.log(todoTotals()); // { total: 3, active: 2 }
todos([...todos(), { id: 4, text: 'New task', done: false}]);
console.log(todoTotals()); // { total: 4, active: 3 } - entire chain updates
Optimization Strategies
Batching Updates
Groups multiple signal updates into a single execution, preventing computations and effects from running until all updates are completed.
Check out the batch API Reference.
const count = signal(0);
const multiplier = signal(1);
const result = computed(() => count() * multiplier());
// Effect runs when result changes
effect(() => {
console.log(`Result: ${result()}`);
});
// Without batching - result computes twice
count(5); // Logs: "Result: 5"
multiplier(2); // Logs: "Result: 10"
// With batching - result computes once
batch(() => {
count(10); // No effect execution yet
multiplier(3); // No effect execution yet
}); // Logs: "Result: 30"
Reactive Stores
Each property (with deep nesting support) in a store is a signal, preventing unnecessary recomputations when unrelated properties change.
Check out the store API Reference.
// ❌ Over-reactive - any property change updates everything
const counter = signal({ value: 0, max: 10, multiplier: 1 });
const displayText = computed(() => `Count: ${counter().value}`);
counter({ ...counter(), priority: 'medium' }); // displayText recalculates unnecessarily
// ✅ Fine-grained - using store for related data
import { store } from '@hellajs/store';
const counter = store({
value: 0,
max: 10,
multiplier: 1,
});
const displayText = computed(() => `Count: ${counter().value()}`);
counter().multiplier(2); // Updates multiplier, displayText unaffected
counter().value(1); // Updates value, displayText recalculates
console.log(displayText()); // "Count: 1"
Untracked Reads
Read signal values without establishing a dependency relationship and prevent triggering updates when that signal changes.
Check out the untracked API Reference.
const count = signal(0);
const expensiveComputation = computed(() => {
// Simulate expensive operation
return untracked(() => {
console.log('Expensive computation running');
return count() * 1000;
});
});
Effect Cleanup
Effects return a cleanup function that removes the effect from the reactive system, preventing memory leaks.
const count = signal(0);
const cleanup = effect(() => {
console.log(`Count: ${count()}`);
});
// Later, when no longer needed
cleanup(); // Stops tracking count changes
Internal Mechanics
The reactive system uses a graph-based approach to track dependencies and propagate changes efficiently.
Reactive Nodes
Every signal, computed, and effect is a reactive node in the dependency graph. Each node maintains.
- Dependencies - The reactive values it reads from
- Subscribers - The reactive computations that read from it
- State flags - Internal status for efficient update coordination
Signal A ── Computed B ── Effect C
↑ ↑ ↑
(source) (dependency) (subscriber)
Dependency Graph
The reactive system forms a directed graph where each edge represents a dependency relationship. This graph structure enables efficient propagation of changes from source signals to all dependent computations and effects. The graph uses doubly-linked connections for efficient addition and removal of dependencies as computations re-execute and establish new dependency relationships.
State Flags
Each reactive node uses efficient bit flags to track its current state throughout the update cycle.
- Clean - The value is current and no recalculation is needed
- Dirty - Dependencies have changed and re-evaluation is required
- Pending - Marked during propagation as potentially needing updates
- Computing - Currently executing a computation function
- Tracking - Actively recording new dependencies during execution
Update Propagation
When a signal changes, the system orchestrates updates through a carefully designed process.
- Mark Subscribers - All dependent nodes are marked as dirty
- Schedule Effects - Effects are queued for immediate execution
- Process Updates - The system processes updates in dependency order
- Lazy Evaluation - Computed values recalculate only when accessed
- Execute Effects - Scheduled effects run after all signal updates complete
This hybrid approach optimizes performance by making computed values lazy (calculated on-demand) while keeping effects eager (executing immediately when dependencies change).
Memory Management
The reactive system includes automatic memory management to prevent leaks and optimize performance.
- Dependency Cleanup - Old dependencies are automatically removed when computations re-execute
- Link Recycling - Internal connection objects are reused to minimize memory allocation
- Effect Disposal - Cleanup functions fully disconnect effects from the dependency graph