Angular Signals: Effective Effects

Today we’ll cover how effects works, and how we can use those concepts to write effective effects. Additionally, many of these concepts can be applied to computed. This can also be a good primer for the next article Advanced Signals in this signals series (Link when available).

First, if you’re unfamiliar with Angular signals or signals in general. See Angular Signals in 3 Minutes

How Effects Work

Signals are not Observables

Signals and Observables both solve problems of reactivity. But, they differ in behavior on key concepts. Given the example, how many times is ‘Observable’ logged? How many times is ‘Signal’ logged?

RxJS
source = new BehaviorSubject(1);
source$ = source.pipe(tap(() => console.log('Observable')))
source$.subscribe();
source$.subscribe();
source$.subscribe();

setTimeout(() => source.next(2), 1)
Signals
source = signal(1)
log_source = effect(() => console.log('Signal'))
source();
source();
source();

setTimeout(() => source.set(2), 1)

Observables: When the source receives a new value, it emits that value to all of its subscribers. Thus, if we have a tap + 3 subscribes we will run that tap 3 times. In this context, 1 emission for the initial value of 1 and 1 emission when next is called with the value of 2. For a total of 6 logs.

Signals: Regardless of how many times we get the value from the signal, it doesn’t change when the effects callback is invoked. Only on start, and when its dependencies change, will the effect execute its function. In this context, 1 log on initialization, 1 log after calling set. For a total of 2 logs.

When do effects run?

Like computed, effects run their callback once on start and when their dependencies change. Effects create dependencies by tracking any signals referenced in their callback.

There are two exceptions to this

  • Signals referenced inside of untracked
  • Signals after an await operation (more on this later)

But, once we have a dependency, how do we determine if that dependency has changed to then run the effects callback?

Signals have an internal equality function (this can be overwritten). This function will be used to check whether the new value is different than the previous one.

Basic Javascript Equality
`{} === {}` // → false
`'A' === 'A'` // → true
`[] === []` // → false

In an earlier article, I referenced how signals bring a lot of simplicity to Angular. Using fundamental Javascript knowledge, we can better understand and optimize signals. - Let’s start with an unoptimized example for context.

Object Dependency

Consider how the snippet below might behave, given our knowledge of basic Javascript equality.

active_notebook.update(v => ({...v}))

log_id = effect(() => {
  if (active_notebook().id) {
    console.log(active_notebook().id)
  }
})

Even though none of the property values have changed. The effects callback will still run because the previous active_notebook is not equal to the current active_notebook.

Writing Effective Effects

Prefer Primitives in Effects

Based on the last section, we could optimize our effect to only fire when the id changes. Using computed we can destructure objects into a primitive that we can use in our effect.

Primitive Dependency

Now the effect only tracks active_id as a dependency, and will only invoke its callback if active_id has changed.

active_id = active_notebook.computed((notebook) => notebook.id)
log_id = effect(() => {
  if (active_id) {
    console.log(active_id)
  }
})

This is something people coming from rxjs struggle with initially (myself included).

If the active_id computed will invoke its callback everytime that active_notebook changes, why doesn’t everything downstream of it get invoked as well? - The signals equality function sort of acts like a built-in distinctUntilChanged. If the signals previous value is equal to its current value, it will not be marked as changed and the effects callback will not execute.

In other words our equality check now looks something like this

Signal Equality
// Before
{id: '123'} === {id: '123'} // → false

// After
'123' === '123' // → true

Define Dependencies at the Top-Level

Now that we understand how a misused dependency can caused unwanted behavior, lets look at patterns to prevent that hassle.

For context, effects will track dependencies regardless of how deeply nested they are in functions, classes, etc… We want to create a pattern where

  • We can clearly tell when the effect should run
  • Future developers won’t accidentally add a dependency
Sneaky Signal Example

The following will track sneaky_signal as a dependency even though its “hidden” in a separate function (and class)

class MyClass {
  value = signal('')
  log_value = effect(() => {
     this.logger.log(this.value())
  })
}


class LoggerService {
  sneaky_signal = signal(true);

  log(value) {
    if (this.sneaky_signal()) {
      console.log(value);
    }
  }
}

We can resolve this by doing two things

  1. Defining dependencies at the top-level of the effect callback (not nested)
  2. Using untracked on any functions/statements called later in the callback. (Especially useful in our example, since sneaky_signal is defined in the logger)
Explicit Signal Example

Our dependencies are clearly defined at the top level, and any outside or nested functions are called within untracked. It’s clear when this effects callback will be invoked.

class MyClass {
  value = signal('')
  log_effect = effect(() => {
    const value = this.value();
    untracked(this.logger.log(value))
  })
}

💡 Even though value is passed to the untracked portion, this effect will still react to changes to value since it was referenced outside of untracked

Inspiration / Examples

That’s it. To end this article, here are a couple of examples of effects you could write, following the patterns and principles mentioned.

Scroll an element when id changes

  • Without computed you would scroll to the top on every change to content
  • You don’t actually need untracked here. It’s more-so to prevent future dependencies being added by accident
scroller.directive.ts
class Scroller {

  active_note = signal({ id: '1234', content: 'Markdown text...' })

  active_id = computed(() => this.active_notebook().id)
  scroll_on_change = effect(() => {
     const active_id = this.active_id();
     untracked(this.scroll())
  })

  constructor(element: ElementRef) { }

  scroll = () => this.element.nativeElement.scrollTop = 0;

}

Syntax highlight on value changes

highlight.directive.ts
import prism from 'prismjs';

class Highlight {

  loaded = signal(false);
  active_note = signal({ id: '1234', content: 'Markdown text...' })
  content = computed(() => this.active_notebook().content)
  
  highlight_on_change = effect(() => {
    const content = this.content();
    const loaded = this.loaded();
    if (loaded && content) {
      untracked(this.highlight())
    }
  })

  constructor(element: ElementRef) {
    prism.plugins.autoloader.languages_path = `path...`;
    this.loaded.set(true);
  }

  highlight = () => highlightAllUnder(this.element.nativeElement)

}

Read the rest of the series here.


More Angular Signals

Enjoyed this article? Content similar to this is available on Flotes as studyable Notebooks. Information is delivered in an Anki-style flashcard way, that allows you to fill in blanks and evaluate difficultly, to maximize learning efficiency.

📡 Angular Signal Effects

Thu Oct 19 2023