The Reactive Core
A reactive system that automatically tracks dependencies between data and computations, eliminating manual subscriptions.
Reactive Primitives
Signals - Reactive State
A signal notifies dependent computations when its value changes.
import { signal } from '@hellajs/core';
const count = signal(0);
console.log(count()); // 0
count(5); // Update value
console.log(count()); // 5
Computed - Derived Values
A computed signal derives its value from other signals.
import { signal, computed } from '@hellajs/core';
const count = signal(0);
const doubled = computed(() => count() * 2);
console.log(doubled()); // 0
count(5);
console.log(doubled()); // 10
Effects - Reactive Side Effects
An effect runs when reactive dependencies change.
import { signal, computed, effect } from '@hellajs/core';
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 Complex Data
Objects and Arrays
Both signal and computed use deep equality comparison to detect changes in objects and arrays. This prevents unnecessary updates when content is equivalent.
import { signal } from '@hellajs/core';
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 }); // Code runs but no reactive update
todos()[0].done = true; // Code runs but no reactive update
// ✅ Setting new arrays with different content triggers updates
todos([
{ id: 1, text: 'Learn HellaJS', done: false },
{ id: 2, text: 'Build an app', done: true } // Changed done to true
]); // Triggers update
// ✅ Setting new arrays with same content doesn't trigger unnecessary updates
todos([
{ id: 1, text: 'Learn HellaJS', done: false },
{ id: 2, text: 'Build an app', done: true } // Same content as before
]); // No update - content is identical
Performance Considerations
Deep equality comparison has performance implications for large objects and arrays. Consider these patterns:
import { signal, computed } from '@hellajs/core';
// ❌ Expensive deep comparison on large arrays
const largeDataset = signal(new Array(10000).fill(0).map((_, i) => ({ id: i, value: i })));
// ✅ Use reference-based updates for better performance
const largeDataset = signal(new Array(10000).fill(0).map((_, i) => ({ id: i, value: i })));
const optimizedUpdate = (newItem) => {
const current = largeDataset();
if (current.some(item => item.id === newItem.id)) return; // No change needed
largeDataset([...current, newItem]); // Reference change triggers update
};
// ✅ Break large objects into smaller signals
const userProfile = signal({ name: 'John', email: 'john@example.com' });
const userPreferences = signal({ theme: 'dark', notifications: true });
Async in Effects
Effects should handle asynchronous operations manually. The effect function itself should not be async, but can contain async operations.
import { signal, effect } from '@hellajs/core';
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);
Advanced Reactive Patterns
Conditional Dependencies
Signals only become dependencies when actually read during execution, enabling dynamic dependency graphs.
import { signal, computed } from '@hellajs/core';
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 when view is 'counter'
} else {
return `Todos: ${todos().length}`; // Only tracks todos when view is 'todos'
}
});
// Initially view is 'counter', so only count and view are dependencies
todos([...todos(), { id: 1, text: 'New todo', done: false }]); // currentTitle doesn't update
view('todos'); // currentTitle updates and now tracks todos instead of count
count(10); // currentTitle doesn't update because count is no longer a dependency
Nested Effects and Cleanup
Effects can create other effects, with automatic cleanup when the parent effect re-runs.
import { signal, effect } from '@hellajs/core';
const activeUserId = signal(1);
const userData = signal(null);
effect(() => {
const userId = activeUserId();
// This inner effect is automatically cleaned up when activeUserId changes
const userDataCleanup = effect(() => {
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(data => userData(data));
});
// Manual cleanup for additional resources
const interval = setInterval(() => {
console.log(`Polling data for user ${userId}`);
}, 5000);
// Cleanup function runs when activeUserId changes
return () => {
clearInterval(interval);
// userDataCleanup is automatically called by the reactive system
};
});
activeUserId(2); // Cleans up previous effect and starts new one
Transformation Chains
Computed values create transformation pipelines that efficiently propagate changes through complex data flows.
import { signal, computed } from '@hellajs/core';
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
Group signal updates into a single execution using batch. Computations and effects run until all updates are completed.
import { signal, computed, effect, batch } from '@hellajs/core';
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"
Fine-grained Updates
Design reactive state to minimize unnecessary recomputations by separating concerns into individual signals.
import { signal, computed } from '@hellajs/core';
// ❌ 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 - separate signals for independent concerns
const counterValue = signal(0);
const counterMax = signal(10);
const counterMultiplier = signal(1);
const displayText = computed(() => `Count: ${counterValue()}`);
counterMultiplier(2); // displayText unaffected
counterValue(1); // displayText recalculates
console.log(displayText()); // "Count: 1"
Untracked Reads
Use untracked to read signal values without establishing a dependency relationship.
import { signal, computed, untracked } from '@hellajs/core';
const count = signal(0);
const multiplier = signal(10);
// Without untracked - recomputes when either signal changes
const trackedResult = computed(() => {
console.log('Tracked computation running');
return count() * multiplier(); // Depends on both signals
});
// With untracked - only recomputes when count changes
const untrackedResult = computed(() => {
console.log('Untracked computation running');
return count() * untracked(() => multiplier()); // Only depends on count
});
// Test the behavior
console.log(trackedResult()); // "Tracked computation running" -> 0
console.log(untrackedResult()); // "Untracked computation running" -> 0
count(5);
// Both logs appear - both computations run
multiplier(20);
// Only "Tracked computation running" appears
// untrackedResult doesn't recompute because it doesn't track multiplier
Previous Computed Value
Computed functions receive the previously computed value as an optional parameter, enabling incremental calculations.
import { signal, computed } from '@hellajs/core';
const items = signal([]);
const runningTotal = computed((previousTotal = 0) => {
const currentItems = items();
// Calculate only the sum of new items
const newSum = currentItems
.slice(previousTotal.lastIndex || 0)
.reduce((sum, item) => sum + item.value, 0);
return {
total: previousTotal.total + newSum,
lastIndex: currentItems.length
};
});
items([{ value: 10 }, { value: 20 }]);
console.log(runningTotal().total); // 30
items([...items(), { value: 15 }]);
console.log(runningTotal().total); // 45 (incrementally calculated)
Effect Cleanup
Effects return a cleanup function that removes the effect from the reactive system, preventing memory leaks.
import { signal, effect } from '@hellajs/core';
const count = signal(0);
const cleanup = effect(() => {
console.log(`Count: ${count()}`);
});
// Later, when no longer needed
cleanup(); // Stops tracking count changes
Testing with flush()
When testing DOM updates, use flush()
to ensure effects run before assertions.
import { signal, flush } from '@hellajs/core';
import { mount } from '@hellajs/dom';
// In tests - force effects to run before checking DOM
const count = signal(0);
mount(() => ({ tag: 'div', children: [count] }));
count(5);
flush(); // Ensure DOM is updated before assertion
expect(document.querySelector('div').textContent).toBe('5');
Error Handling
Errors in reactive functions can break dependency tracking. Use try-catch blocks carefully:
import { signal, computed, effect } from '@hellajs/core';
const data = signal({ value: 10 });
const riskyOperation = signal(true);
// ❌ Unhandled errors can break reactivity
const badComputed = computed(() => {
if (riskyOperation()) {
throw new Error('Computation failed');
}
return data().value * 2;
});
// ✅ Handle errors within reactive functions
const safeComputed = computed(() => {
try {
if (riskyOperation()) {
throw new Error('Computation failed');
}
return data().value * 2;
} catch (error) {
console.error('Computation error:', error);
return 0; // Fallback value
}
});
// ✅ Use error boundaries in effects
effect(() => {
try {
const result = safeComputed();
console.log(`Safe result: ${result}`);
} catch (error) {
console.error('Effect error:', error);
}
});
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 signals 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