Signals from scratch

Decoupling components with signals in modern web development

September 09, 2025

In modern web development, handling communication between different parts of an application efficiently can make or break your code’s maintainability. This is where signals come in. Signals provide a clean way to broadcast events and react to them, without creating tight coupling between components. In this post, we’ll explore what signals are, why they matter, and even build a simple signal system from scratch.

What are signals and why do they matter?

Imagine you have multiple parts of your application that need to react when something happens, like updating a counter, showing a notification, or refreshing a component, but you don’t want these parts tightly connected. This is where signals come in.

A signal is a simple mechanism to broadcast events and react to them. Think of it as a messenger that tells subscribers, “Hey, something happened!” without forcing them to know exactly who sent the message or how it works internally.

Signals are useful because they help you:

  • - Decouple components: Components don’t need to know about each other’s internals.
  • - Manage side effects cleanly: Actions can happen in response to signals instead of being manually orchestrated.
  • - Keep code predictable: By centralizing reactions to events, you can easily track what triggers what.

Signals from scratch

Enough theory, lets dive in. Our plan is to create a signal, a derive function and an effect function.

First, we will create a function called 'signal' that manages a reactive value. A signal stores a value and lets other parts of your code automatically react whenever that state changes.

export function signal<T>(defaultValue: T) {
  const subscriptions = new Set<Function>()
  let value = defaultValue

  return {
    get value(): T {
      if (subscriber) {
        subscriptions.add(subscriber)
      }

      return value
    },
    set value(newValue: T) {
      value = newValue
      subscriptions.forEach((fn) => fn())
    },
  }
}

Let's break this down

const subscriptions = new Set<Function>()

Here we keep track of all the functions (effects) that need to run when the signal value changes.

get value(): T {
  if (subscriber) {
    subscriptions.add(subscriber)
  }

  return value
},

The getter returns the current value of the signal. If there's an active subscriber, that subscriber is added to the subscriptions set. Any effect that reads the signal is automatically registered to re-run later if the signal values changes.

set value(newValue: T) {
  value = newValue
  subscriptions.forEach((fn) => fn())
},

When the signal value is updated, every subscribed function gets called with the new value.

In the example we can see that every effect is triggered once during creation and then again when we change the value of 'var1'.

Pretty neat so far! But the real question is: how do we use these signals now in our code?

Ramping up

What we’ve built here is essentially how signals work under the hood in many modern frameworks. One big missing piece, though, is cleanup. Without it, when a component is destroyed, the signal will keep its subscriber in memory and continue to call it on every change. In real-world implementations, cleanup is critical to avoid memory leaks.

Share

Comments (0)