DEV Community

Cover image for Understanding Signals and Effects in Angular
bytebantz
bytebantz

Posted on

Understanding Signals and Effects in Angular

In Angular development, understanding signals and effects is pivotal for managing state and handling asynchronous operations effectively. Signals serve as wrappers around values, while effects are operations triggered by changes in these signals. Let’s delve into these concepts and explore their practical applications.

What are signals?

A signal in Angular is a wrapper around a value that notifies its consumers when the value changes.

Signals can be either writable or read-only and can contain any type of value, from primitives to complex objects.

1. Writable signals

Writable signals are simple, direct holders of a value. When you create a writable signal, its value is immediately available, and you can access and modify it directly at any time.

Creating a Writable Signal:

const count = signal(0);
console.log('The count is: ' + count());
Enter fullscreen mode Exit fullscreen mode

Setting a New Value:

count.set(3);
Enter fullscreen mode Exit fullscreen mode

Updating the Value:

count.update(value => value + 1);
Enter fullscreen mode Exit fullscreen mode

2. Computed signals

Computed signals derive their values from other signals and automatically update when those signals change. They are read-only which means you cannot directly assign values to a computed signal.

They are defined using the computed function.

Creating a Computed Signal:

const count = signal(0);
const doubleCount = computed(() => count() * 2);
Enter fullscreen mode Exit fullscreen mode

Lazy Evaluation: The computation for doubleCount doesn’t run until doubleCount is accessed for the first time.

Memoization: The calculated value is cached. If the source signals change, the cached value is invalidated, and the new value is recalculated when next read.

Effects

An effect is an operation that runs whenever one or more signal values change.

Effects run at least once and re-run whenever the signals they depend on change.

Effects always execute asynchronously, during the change detection process.

Creating an Effect

const count = signal(0);

effect(() => {
  console.log(`The current count is: ${count()}`);
});

// Outputs: "The current count is: 0"
count.set(2); // Outputs: "The current count is: 2"
Enter fullscreen mode Exit fullscreen mode

Use cases for effects

Effects can be useful for:

  • Logging data changes
  • Syncing data with local storage
  • Custom DOM behavior (e.g., changing background color based on State)
  • Custom rendering (e.g., drawing on a canvas whenever a signal changes)

Using effects for state propagation can result in ExpressionChangedAfterItHasBeenChecked errors, infinite circular updates, or unnecessary change detection cycles.

Angular by default prevents you from setting signals in effects. It can be enabled if absolutely necessary by setting the allowSignalWrites flag when you create an effect.

const count = signal(0);
const doubleCount = signal(0);

effect(() => {
  doubleCount.set(count() * 2);
}, { allowSignalWrites: true });

count.set(1);  // This will now work but use cautiously
console.log(doubleCount());  // Outputs: 2
Enter fullscreen mode Exit fullscreen mode

However instead of using effects for state propagation, it’s better to utilize computed signals to model state that depends on other state. This approach can help keep your application more predictable and manageable.

Effect Execution Context

By default, you can only create an effect() within an injection context (where you have access to the inject function) such as a component, directive, or service constructor.

To create an effect outside the constructor, you need to pass an Injector instance to the effect via its options. Passing an Injector instance to an effect’s options when creating it outside the constructor ensures that the effect has access to Angular’s dependency injection system.

@Component({...})
export class EffectiveCounterComponent {
  readonly count = signal(0);
  constructor(private injector: Injector) {}
  initializeLogging(): void {
    effect(() => {
      console.log(`The count is: ${this.count()}`);
    }, {injector: this.injector});
  }
}
Enter fullscreen mode Exit fullscreen mode

Destroying Effects

Effects are automatically destroyed when their enclosing context (such as a component, directive, or service) is destroyed.

They can also be manually destroyed using the the EffectRef’s .destroy() method. You can combine this with the manualCleanup option to create an effect that lasts until it is manually destroyed.

export class DoubleCounterComponent {
  readonly count = signal(0);
  readonly doubleCount = computed(() => this.count() * 2);

  private doubleCountEffect: EffectRef;

  constructor() {
    this.doubleCountEffect = effect(() => {
      console.log(`The double of count is: ${this.doubleCount()}`);
    }, { manualCleanup: true });
  }

  destroyEffect() {
    this.doubleCountEffect.destroy();
  }
}
Enter fullscreen mode Exit fullscreen mode

Reading Without Tracking Dependencies

Sometimes, you may want to read a signal without creating a dependency. This can be done using untracked.

effect(() => {
  console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});
Enter fullscreen mode Exit fullscreen mode

untracked is also useful when an effect needs to invoke some external code which shouldn’t be treated as a dependency:

effect(() => {
  const user = currentUser();
  untracked(() => {
    // If the `loggingService` reads signals, they won't be counted as
    // dependencies of this effect.
    this.loggingService.log(`User set to ${user}`);
  });
});
Enter fullscreen mode Exit fullscreen mode

This approach ensures that your effects behave predictably and only re-run when you intend them to, avoiding unnecessary computations.

Effect cleanup functions

Effects can register cleanup functions to handle things like canceling a timeout if the effect runs again or is destroyed.

This ensures the timer is cleared if the effect re-runs before the timeout completes.

effect((onCleanup) => {
  const timer = setTimeout(() => {
    console.log('Timer finished');
  }, 1000);
  onCleanup(() => {
    clearTimeout(timer);
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

In conclusion, signals and effects form essential constructs in Angular for managing state, handling asynchronous operations, and ensuring efficient change detection. Understanding and leveraging these concepts appropriately contribute to building robust and scalable Angular applications.

CTA

💛If you enjoy my articles, consider subscribing to my newsletter to receive my new articles by email

Top comments (0)