Wanna see something cool? Check out Angular Spotify 🎧

Learn Angular Signals by implementing your own

Angular Signals mark a major shift in how change detection works in Angular. They give Angular a proper reactivity system — no longer relying heavily on Zone.js.

But what is reactivity, really?

At its core, reactivity is about keeping your data and UI in sync. When data changes, the UI should update. When the UI triggers a change, the data should reflect that. It is a two-way relationship.

Signals are Angular’s new primitive for managing this relationship. A signal wraps a value and notifies anything that depends on it when that value changes.

In Angular’s own words: A signal is a lightweight wrapper around a value. Angular tracks where signals are read and when they are updated. This allows Angular to respond to state changes and update the DOM efficiently. This behaviour is what we call reactivity.

A basic Signals usage in Angular looks like this:

const count = signal(0);

// Signals are getter functions — calling them reads their value
console.log('The count is: ' + count());

// Update the value
count.set(3);

// Update based on the previous value
count.update(value => value + 1);

There is also computed() for derived values:

const count = signal(0);
const doubleCount = computed(() => count() * 2);

These are lazily evaluated and memoised — Angular only re-calculates doubleCount when count changes.

Let implement a minimal version of signal and computed from scratch to understand how they work.

Building a simple Signals

Step 0: Define types

We use TypeScript to define two types:

type Signal<T> = () => T;

type WritableSignal<T> = Signal<T> & {
  set: (value: T) => void;
};

A Signal<T> is just a getter function. A WritableSignal<T> is one that also has a set() method.

Step 1: A basic signal()

This is a signal that holds a value, lets you read it

function signal<T>(initial: T): WritableSignal<T> {
  let value = initial;

  const getValue = () => value;

  getValue.set = (newValue: T) => {
    value = newValue;
  };

  return getValue
}
  • getValue is just a function that returns the current value.
  • We attach a set method to it so it can also update the value.
  • This works because in JavaScript, functions are objects. You can attach extra properties to a function just like you would with any object. In this case, getValue.set is just a property we add to the function.

With the above code, the following example will work

const count = signal(0);

function logCount() {
  console.log('Count is:', count());
}

logCount(); // Count is: 0

count.set(3);

logCount(); // Count is: 3

Here is your cleaned up and reflowed Step 2, with duplications removed and the structure improved for easier reading:


Step 2. Implement computed

A computed signal derives its value from other signals. It is a read-only signal whose value is calculated by a function you provide.

function computed<T>(fn: () => T): Signal<T> {
  let value: T = fn();
  const getValue = () => value;
  return getValue;
}

In this implementation:

  • We invoke fn() once during creation:

    let value: T = fn();

    This means the computed value is calculated only once when computed() is called, and the result is cached in value.

  • The returned getValue() function always returns this cached value.

You might wonder: why not write const getValue = () => fn(); and let it recalculate every time?

Here is why we do not:

  • Real computed signals cache their values and recalculate only when dependencies change.
  • If we call fn() inside getValue(), it will re-run on every access, even if the underlying signals have not changed.
  • It removes any form of caching or memoisation, causing redundant computations.
  • Most importantly, it breaks the reactive model — in signals, updates are pushed (by dependency changes), not pulled (by re-accessing).

Here is an example:

const count = signal(1);
const doubleCount = computed(() => count() * 2);

console.log(doubleCount()); // 2

count.set(5);

console.log(doubleCount()); // 2 (still old value!)

Why does doubleCount() still return 2 after updating count?

  • Because fn() ran only once, and we are always returning the cached value.
  • The computed signal has no way of knowing that count changed — signals do not notify anyone yet.

To fix this, we need to:

  • Let signals track who depends on them
  • Notify those dependents (subscribers) when the value changes

That is what we will address in Step 3.


Step 3. Add subscriber tracking

In Step 2, we saw that even after updating the original signal, the computed value does not change. To make this reactive, we need to:

  • Keep track of which computed function depends on which signals
  • Notify those functions when a signal’s value changes

Let update our signal and computed implementations to support that.


First, we define a global currentSubscriber variable. When a computed function runs, it registers itself as the currentSubscriber. Then when a signal is read, it stores this subscriber in its list.

let currentSubscriber: (() => void) | null = null;

Update signal():

function signal<T>(initial: T): WritableSignal<T> {
  let value = initial;
  const subscribers = new Set<() => void>();

  const getValue = () => {
+    if (currentSubscriber) {
+      subscribers.add(currentSubscriber);
+    }
    return value;
  };

  getValue.set = (newValue: T) => {
    value = newValue;
+    for (const sub of subscribers) {
+      sub();
+    }
  };

  return getValue as WritableSignal<T>;
}

Now update computed():

function computed<T>(fn: () => T): Signal<T> {
  let value: T;

+  const recompute = () => {
+    currentSubscriber = recompute;
+    value = fn();
+    currentSubscriber = null;
+  };

+  recompute();

  const getValue = () => value;

  return getValue;
}

Usage:

const count = signal(1);
const doubleCount = computed(() => count() * 2);

console.log(doubleCount()); // 2

count.set(5);

console.log(doubleCount()); // 10

Why this works

  • When computed(fn) runs, it sets currentSubscriber to its recompute function.
  • Any signals accessed during fn() will store recompute as a subscriber.
  • Later, when count.set(5) is called, the count signal notifies its subscribers, which includes recompute.
  • So recompute() is called, and value is updated.
  • Now doubleCount() will return the updated value.

This is the minimal reactive system: signals track dependencies via subscriptions, and computed values get updated automatically.

Source Code

References

Published 1 Apr 2025

    Read more

     — Cursor: Customize Your Sidebar Like VS Code
     — bundle install: Could not find MIME type database in the following locations
     — Netlify Redirects vs Gatsby Redirects: My Traffic Drop 90%
     — Understanding staleTime and gcTime (cacheTime) in React Query
     — Chrome DevTools Performance Panel: Analyze Your Website's Performance