Skip to content

Signal Primitives: Signal, Computed, Effect

expert20 min read

Three Primitives to Rule Them All

Every signal system -- Solid, Vue, Angular, Preact, the TC39 proposal -- is built from exactly three primitives. If you deeply understand these three, you understand all of them. The APIs differ, the names differ, but the mechanics are identical.

  1. Signal -- a reactive value you can read and write
  2. Computed -- a derived value that auto-updates when its dependencies change
  3. Effect -- a side effect that re-runs when its dependencies change

That's it. Every reactive UI framework is built from these three Lego bricks arranged in a directed acyclic graph.

const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => console.log(`Doubled: ${doubled.value}`));

count.value = 5;

The Mental Model

Mental Model

Think of a spreadsheet. Cell A1 holds a raw value (42) -- that's a signal. Cell B1 has a formula =A1 * 2 -- that's a computed. A chart that visualizes B1 -- that's an effect. When you change A1, B1 recalculates automatically, and the chart redraws. You never manually tell B1 to update. The spreadsheet tracks which cells depend on which, and propagates changes through the graph.

Signal systems work exactly the same way. The spreadsheet is the dependency graph. Cells are signals and computeds. Charts are effects. The "spreadsheet engine" is the reactive runtime.

Primitive 1: Signal (Reactive State)

A signal is the simplest reactive primitive: a container for a value that notifies subscribers when it changes.

// Every framework's signal, conceptually:
const temperature = signal(72);

// Read the value
console.log(temperature.value);

// Write a new value
temperature.value = 75;

Under the hood, a signal does two things:

  1. On read: if there's an active tracking context (a running computed or effect), register that context as a subscriber
  2. On write: notify all subscribers that the value changed

That's the entire API. The magic is in what happens at read time.

// Simplified signal implementation
function signal(initialValue) {
  let value = initialValue;
  const subscribers = new Set();

  return {
    get value() {
      if (activeComputation) {
        subscribers.add(activeComputation);
      }
      return value;
    },
    set value(newValue) {
      if (newValue !== value) {
        value = newValue;
        for (const sub of subscribers) {
          sub.notify();
        }
      }
    }
  };
}
Info

The activeComputation variable is a global (module-scoped) reference to whatever computed or effect is currently executing. This is the "tracking context" pattern. We'll explore it deeply in the automatic dependency tracking topic.

Signal equality

Signals only notify subscribers when the value actually changes. Most implementations use Object.is for comparison:

const count = signal(0);
count.value = 0;  // No notification -- same value
count.value = 1;  // Notification -- value changed

const user = signal({ name: 'Alice' });
user.value = { name: 'Alice' };  // Notification! Different object reference
Common Trap

Object signals trigger updates on reference change, not deep equality. Setting user.value = { name: 'Alice' } creates a new object, so Object.is returns false even though the contents are identical. This is the same behavior as React's useState with objects. If you need deep reactivity on object properties, you need something like Vue's reactive() (Proxy-based) or Solid's stores.

Quiz
Given this code, how many times does the effect's callback execute after initial setup?

Primitive 2: Computed (Derived State)

A computed is a value derived from other reactive values. It's lazy (only evaluates when read), cached (reuses the last value if dependencies haven't changed), and automatically tracks its dependencies.

const firstName = signal('Alice');
const lastName = signal('Smith');

const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});

console.log(fullName.value);
// The callback ran, tracking firstName and lastName as dependencies
// Result: 'Alice Smith'

console.log(fullName.value);
// Dependencies haven't changed, so returns cached value
// The callback did NOT run again

firstName.value = 'Bob';
console.log(fullName.value);
// firstName changed, so the callback runs again
// Result: 'Bob Smith'

Lazy evaluation

This is crucial: computeds don't eagerly recalculate when dependencies change. They mark themselves as "dirty" and wait until someone reads their value. This means if nobody reads fullName after firstName changes, the computation never runs.

const count = signal(0);
const expensive = computed(() => {
  // Imagine this takes 100ms
  return heavyCalculation(count.value);
});

count.value = 1;   // expensive is now "dirty" but hasn't recalculated
count.value = 2;   // still dirty, still hasn't recalculated
count.value = 3;   // still dirty

console.log(expensive.value);  // NOW it recalculates, using count = 3
// The heavy calculation ran exactly once, not three times

This lazy evaluation is a huge performance win. If a computed's value is only needed in certain UI branches (conditional rendering), it never wastes work when that branch is hidden.

Computeds as signals

A computed acts as a signal to anything that reads it. It can have its own subscribers:

const a = signal(1);
const b = computed(() => a.value * 2);
const c = computed(() => b.value + 10);

effect(() => console.log(c.value));

The dependency graph: a → b → c → effect. When a changes, the system walks the graph: mark b dirty, mark c dirty, re-run the effect (which reads c, which reads b, which reads a).

Quiz
In a signal system, when does a computed value's derivation function actually execute?

Primitive 3: Effect (Side Effects)

An effect is a computation that performs side effects and re-runs when its dependencies change. Unlike computeds, effects are eager -- they execute immediately and re-execute whenever a dependency changes.

const theme = signal('dark');

effect(() => {
  document.body.className = theme.value;
});
// Immediately executes: body.className = 'dark'

theme.value = 'light';
// Effect re-executes: body.className = 'light'

Effects are the bridge between the reactive world and the imperative world. They're how signals connect to the DOM, to APIs, to anything outside the reactive graph.

Effect cleanup

Effects often need to clean up previous side effects before re-executing. Every signal framework provides a cleanup mechanism:

effect((onCleanup) => {
  const id = setInterval(() => {
    console.log(`Polling for user ${userId.value}`);
  }, 1000);

  onCleanup(() => clearInterval(id));
});

When userId changes, the cleanup from the previous execution runs first (clearing the old interval), then the effect re-executes (starting a new interval for the new user).

Effects vs Computeds

This distinction trips people up. Here's the rule:

Key Rules
  1. 1Use computed when you are deriving a value from other reactive values -- it is lazy, cached, and has no side effects
  2. 2Use effect when you need to perform a side effect (DOM mutation, API call, logging) -- it is eager and re-runs immediately
  3. 3Never use an effect to derive a value and store it in a signal -- that is what computed is for
  4. 4Effects are the leaves of the dependency graph, computeds are intermediate nodes
// WRONG: using effect to derive a value
const count = signal(0);
const doubled = signal(0);
effect(() => {
  doubled.value = count.value * 2;
});

// RIGHT: using computed to derive a value
const count = signal(0);
const doubled = computed(() => count.value * 2);

The effect version creates an unnecessary subscription loop, runs eagerly even if nothing reads doubled, and can cause glitches (more on that next).

What developers doWhat they should do
Using effect to sync two signals
Effects are for side effects (DOM, network, logging). Derived values should use computed for lazy evaluation and automatic caching
Use computed to derive one value from another
Creating a computed that performs side effects
Computeds are lazy -- they only run when read. A side effect inside a computed may never execute or may execute at unexpected times
Use effect for side effects, computed for pure derivations
Assuming computed recalculates on every dependency change
Lazy evaluation is a core feature. Multiple rapid dependency changes result in a single recalculation when finally read
Computed marks itself dirty and waits for a read
Quiz
What is wrong with this code?

Glitch-Free Execution

A "glitch" is when a computation sees an inconsistent state -- some dependencies have updated and others haven't yet. Consider:

const a = signal(1);
const b = computed(() => a.value * 2);
const c = computed(() => a.value + b.value);

effect(() => {
  console.log(`a=${a.value}, b=${b.value}, c=${c.value}`);
});

When a.value = 2, what should happen?

  • b should become 4 (2 * 2)
  • c should become 6 (2 + 4)
  • The effect should log a=2, b=4, c=6

Without glitch-free execution, the effect might see a=2, b=2, c=4 (old b, partially updated c). That's a glitch.

Topological sorting

Signal systems prevent glitches using topological sorting. Before executing any computations after a signal change, the system:

  1. Marks all downstream nodes as "dirty"
  2. Sorts them by depth in the dependency graph (shallow first)
  3. Evaluates in order: nodes closer to the changed signal first
Execution Trace
Signal change
a.value = 2
Mark b (depth 1) and c (depth 2) as dirty
Evaluate depth 1
b recalculates: 2 * 2 = 4
b is no longer dirty, subscribers notified
Evaluate depth 2
c recalculates: 2 + 4 = 6
c reads updated b, gets correct value
Execute effects
effect logs: a=2, b=4, c=6
Consistent snapshot, zero glitches

This topological ordering guarantees that by the time any node evaluates, all its dependencies have already been updated. The graph is always in a consistent state.

Push vs Pull: the hybrid approach

Pure push systems (like RxJS Observables) propagate values eagerly from source to sink. Pure pull systems evaluate lazily from sink to source. Modern signal systems use a hybrid:

Push the notification, pull the value.

When a signal changes, it pushes a "dirty" flag through the graph (cheap -- just setting a boolean). When something needs a value, it pulls by calling the derivation function (potentially expensive). This means:

  • Unused branches stay dormant (pull benefit)
  • Changed branches are known immediately (push benefit)
  • Multiple rapid changes result in a single evaluation per node (pull benefit)
  • The notification path is O(graph edges), not O(computation cost) (push benefit)

This push-pull hybrid is why signals are fundamentally more efficient than pure push (RxJS) or pure pull (polling) for UI reactivity.

How the Three Primitives Build a UI

Let's trace how these primitives compose into a complete reactive UI:

// State layer: signals
const todos = signal([
  { id: 1, text: 'Learn signals', done: false },
  { id: 2, text: 'Build app', done: false }
]);
const filter = signal('all');

// Derived layer: computeds
const filteredTodos = computed(() => {
  const list = todos.value;
  const f = filter.value;
  if (f === 'all') return list;
  if (f === 'active') return list.filter(t => !t.done);
  return list.filter(t => t.done);
});

const stats = computed(() => {
  const list = todos.value;
  return {
    total: list.length,
    done: list.filter(t => t.done).length,
    remaining: list.filter(t => !t.done).length
  };
});

// Effect layer: DOM updates
effect(() => {
  renderTodoList(filteredTodos.value);
});

effect(() => {
  renderStats(stats.value);
});

When filter.value = 'active':

  • filteredTodos recalculates (it depends on filter) → the list effect re-renders
  • stats does NOT recalculate (it only depends on todos, not filter) → the stats effect does NOT re-run

Changing the filter only updates the list display. The stats display is completely untouched. No memo needed. No useCallback. No useMemo. The granularity is automatic.

Quiz
In the todo app above, what happens when you toggle a single todo's done property by updating the todos signal?

Across Frameworks

The three primitives map directly to every major signal-based framework:

ConceptSolidVue 3AngularPreact SignalsTC39 Proposal
SignalcreateSignal()ref()signal()signal()Signal.State()
ComputedcreateMemo()computed()computed()computed()Signal.Computed()
EffectcreateEffect()watchEffect()effect()effect()Watcher API
Batch updatesbatch()AutomaticAutomaticbatch()Planned
Deep reactivitycreateStore()reactive()No (immutable)No (immutable)Not in scope

The naming differs, the ergonomics differ, but the underlying model is the same three primitives connected in a dependency graph with glitch-free propagation.