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.

  1. Mark Subscribers - All dependent nodes are marked as dirty
  2. Schedule Effects - Effects are queued for immediate execution
  3. Process Updates - The system processes updates in dependency order
  4. Lazy Evaluation - Computed values recalculate only when accessed
  5. 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