Signal
Signals, a reactive programming model introduced in Angular 16, streamline the logic for tracking state changes and enhance performance. Unlike RxJS Observables, Signals encapsulate values, directly detecting changes and automatically updating relevant dependencies.
Key Features of Signals
- Selective Re-rendering: Automatically re-renders only the affected parts of the UI when values change.
- Dependency Tracking: Prevents unnecessary re-calculations by efficiently tracking dependencies.
- Explicit State Management: Enables clear and concise state management.
Signal Operation Principles
Signals wrap specific variables (state) and automatically track where these values are read. When a value changes, all subscribers reading that value are automatically updated.
Signals vs. Observables
- Observables: Stream-based, feature-rich with pipe operators, suitable for various event streams.
- Signals: Focus on single values, simplifying change tracking. Directly linked to component re-rendering for performance optimization.
Example
import { signal, effect } from '@angular/core';
const count = signal(0);
effect(() => {
console.log(`Count is ${count()}`);
});
count.update(n => n + 1);
// Places reading count() are automatically updated
Writable Signals: The Foundation of State Management
Writable Signals are fundamental units for directly modifying state, streamlining and making data flow predictable within applications. Because they instantly track value changes, features such as reactive rendering can be implemented quickly and intuitively.
1. Creating a Signal
When you create a Signal and specify an initial value, changes to that value are automatically tracked and reflected where needed:
import { signal } from '@angular/core';
const count = signal(0);
2. Setting a Value: The set() Method
The set() method directly assigns a new value to a Signal. An update occurs if the values are different, but if the internal structure is completely identical (including arrays and objects), no update occurs.
import { signal } from '@angular/core';
const items = signal(['apple']);
// Update occurs because the value is new
items.set(['banana']);
// No update occurs because the value is the same
items.set(['banana']);
import { signal } from '@angular/core';
const fruit = signal({ name: 'apple', color: 'red' });
// No update occurs because the internal structure is the same
fruit.set({ name: 'apple', color: 'red' });
3. Updating a Value: The update() Method
The update() method calculates and applies a new value based on the previous value.
import { signal } from '@angular/core';
const count = signal(0);
count.update(prev => prev + 1);
- Useful for creating new values based on the previous state.
- Because the entire value is replaced at once, re-rendering can occur even if only a small part is changed.
4. Partial Updates: The mutate() Method
The mutate() method is more efficient when only a part needs to be modified.
import { signal } from '@angular/core';
const todos = signal([{ title: 'Learn signals', done: false }]);
todos.mutate(list => {
list[0].done = true;
});
- Modifies only part of an array or object, avoiding unnecessary full replacement.
- Because the internal structure is directly modified, the managed data structure must be handled carefully.
update() vs. mutate()
- Common ground: Both change Signal values.
- Difference:
- update(): Re-calculates and returns the entire new value.
- mutate(): Directly manipulates the existing value.
- Pros and cons:
- update() is simple in logic but can cause overhead in large data structures because it re-creates the entire value.
- mutate() is efficient because it changes only the necessary parts, but requires careful attention to side effects due to direct references.
Computed Signals
Computed Signals are not writable, so you cannot set() their value. Instead, they subscribe to writable Signal values internally and derive a new value by processing logic whenever the subscribed Signals change.
import { WritableSignal, Signal, signal, computed } from '@angular/core';
const count: WritableSignal<number> = signal(0);
const doubleCount: Signal<number> = computed(() => count() * 2);
// The value of count() is derived through the computed function.
Effects
Effects are a feature that subscribes to Signal value changes and executes specific logic each time the value changes. They should only be used for side effects (like logging, server calls, etc.). Including state changes within an effect can lead to tangled dependencies and unexpected behavior, such as repeated executions or infinite loops.
Example:
import { signal, effect } from '@angular/core';
const count = signal(0);
effect(() => {
// Executes every time the value of count changes, but does not set new state.
console.log(`The current count is: ${count()}`);
});
count.set(1); // count changes, triggering the console message to run again.
As in the example above, Effects should only react to changes without returning new values or directly modifying state.
OnCleanup
The effect function sets up a side effect, and OnCleanup provides a mechanism to clean it up when it’s no longer needed.
import { signal, effect } from '@angular/core';
const currentUser = signal('Guest');
effect((onCleanup) => {
const user = currentUser();
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
onCleanup(() => {
clearTimeout(timer);
});
});
In the code above, the onCleanup callback is called when the effect is cleaned up. That is, when the effect is no longer needed, the function passed to onCleanup is executed to clear the timer set by setTimeout. This is useful for preventing memory leaks or unnecessary timer executions.
Using clearTimeout without onCleanup
clearTimeout(timer) cancels the timer immediately. Therefore, the callback set by setTimeout will not be executed.
import { signal, effect } from '@angular/core';
const currentUser = signal('Guest');
effect(() => {
const user = currentUser();
// clearTimeout executes immediately, cancelling the timer.
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
clearTimeout(timer); // This code cancels the timer right away.
});
Untracked
untracked is a function that allows you to read a Signal’s value inside a reactive context (like effect or computed) without creating a dependency on it.
Normally, an effect automatically tracks all Signals read within it and re-runs whenever any of those Signals change. However, using untracked allows you to intentionally prevent a dependency from being created for a specific Signal, so changes to that Signal will not trigger the effect to run again.
This is useful in specific situations, such as when you only need the current value of a Signal at the moment the effect runs, but don’t want to re-run the effect every time that Signal changes.
import { signal, effect, untracked } from '@angular/core';
const counter = signal(0);
const currentUser = signal('Guest');
effect(() => {
console.log(`User set to '${currentUser()}' and the counter is ${untracked(counter)}`);
});
// Initial output:
// User set to 'Guest' and the counter is 0
// Change the value of currentUser
currentUser.set('Alice'); // The effect runs again, and the log is printed.
// Output: User set to 'Alice' and the counter is 0
// Change the value of counter
counter.set(1); // The effect does not run this time.
// No output
toSignal
Converts an Observable to a Signal.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: ``,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0 });
}
toSignal: Why Convert Observables to Signals?
toSignal is a method that converts an Observable to a Signal to more effectively implement Reactive Programming in Angular. The benefits of this conversion include:
-
Reactive Data Management Signals are optimized for automatically tracking state changes and updating the UI accordingly. While Observables are useful for managing asynchronous data flows, Signals help express and manage state changes more clearly. For example, using toSignal allows a Signal created from an Observable to detect state changes and automatically update the UI, improving code readability and simplifying data flow.
-
Maintaining Consistency between UI and State Signals are advantageous for maintaining consistency between the UI and state. When using Signals, related UI elements automatically update whenever the state changes. This enhances the user experience and reduces the complexity of state management. In the code above, as counterObservable emits a value every second, the counter Signal updates, and the UI is refreshed automatically.
-
Performance Optimization Signals have an optimized structure that prevents unnecessary recalculations. Components are re-rendered only when the value of a Signal changes, improving performance. Converting an Observable to a Signal makes it easy to apply these optimizations.
-
Easy State Updates Signals simplify state updates. The value of a Signal can be set or updated directly, making the process intuitive. In contrast, Observables require various operators and subscription management, which can complicate state updates.
-
Integration with Angular’s Reactive Ecosystem While both Signals and Observables can be used in Angular, combining them with toSignal helps maintain a more consistent reactive programming pattern. This works seamlessly with Angular’s dependency injection and component lifecycle.
Looking back at the example above:
// ...
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0 });
}
Here, an Observable that emits a value every second is created using interval(1000) and converted to a Signal via toSignal. The result is:
- UI is automatically updated: Calling counter() displays a value that changes every second, automatically refreshing the UI.
- State management is simplified: The code becomes more concise as the state can be updated directly through the Signal.
By using toSignal to convert Observables to Signals, you can enjoy several benefits, including reactive data management, UI-state consistency, performance optimization, easy state updates, and integration with Angular’s reactive ecosystem.
Options
The options for toSignal can be used in various ways to configure the Signal.
{
initialValue: any,
requireSync: boolean, // Behaves like BehaviorSubject if true
manualCleanup: boolean // if true, automatic cleanup is disabled
}
- initialValue: Sets the initial value of the Signal. It defines the default state before the Observable emits its first value.
- requireSync: If set to true, the Signal requires the Observable to emit a value synchronously upon subscription. This is useful for Observables that are known to emit immediately, like BehaviorSubject.
- manualCleanup: If set to true, the Signal does not automatically unsubscribe from the source Observable when the component is destroyed. The developer must manually handle the cleanup.
initialValue
Sets the initial value to be used when the Signal is first created. This value defines the initial state of the Signal before the Observable emits.
import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: ``,
})
export class Ticker {
counterObservable = interval(1000);
counter = toSignal(this.counterObservable, { initialValue: 0 });
constructor() {
// The initial value is 0, so 0 is displayed in the UI.
}
}
In this example, setting initialValue to 0 causes 0 to be displayed in the UI when the Signal is first created. This allows you to show a default state to the user before the Observable emits its first value.
requireSync
When this option is set to true, it forces the source Observable to emit its first value synchronously upon subscription. If it doesn’t, an error is thrown. This is useful when you are sure the Observable (like a BehaviorSubject) has a current value and you want to access it immediately.
import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: ``,
})
export class Ticker {
counterSubject = new BehaviorSubject(10); // BehaviorSubject emits synchronously
counter = toSignal(this.counterSubject, { requireSync: true });
constructor() {
// The user immediately gets the latest count value (10) when the component loads.
console.log(this.counter()); // logs 10
}
}
In this example, requireSync: true ensures that the signal is initialized with the current value of the BehaviorSubject right away, without needing an initialValue.
manualCleanup
If this option is set to true, the Signal will not automatically unsubscribe from the source Observable when its context (e.g., the component) is destroyed. The developer must handle the cleanup manually.
import { Component, OnDestroy } from '@angular/core';
import { interval, Subscription } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: ``,
})
export class Ticker implements OnDestroy {
counterObservable = interval(1000);
// manualCleanup is not a direct option for toSignal.
// Manual cleanup is typically handled by managing the subscription yourself.
// The `toSignal` function automatically handles cleanup within an injection context.
// This concept is more applicable if you manage the subscription outside of `toSignal`.
// Correct approach: toSignal handles this automatically.
counter = toSignal(this.counterObservable, { initialValue: 0 });
// If you were to do this manually (for demonstration):
private sub: Subscription;
constructor() {
// this.sub = this.counterObservable.subscribe(...);
}
ngOnDestroy() {
// If you subscribed manually, you would unsubscribe here.
// this.sub.unsubscribe();
console.log('Ticker component destroyed. `toSignal` has cleaned up its subscription automatically.');
}
}
Note: The manualCleanup option is not part of the public API for toSignal as of recent Angular versions. toSignal is designed to work within an injection context (like a component constructor), where it automatically handles cleanup. The concept is important for understanding subscription management in RxJS, but with toSignal, this is handled for you.
toObservable
toObservable is a method that converts a Signal to an Observable. This conversion allows you to use the power of RxJS operators with Signal state changes, delivering them to subscribers as an Observable stream.
Benefits
- Reactive Programming: Observables are excellent for managing asynchronous data streams. Converting a Signal to an Observable allows you to use RxJS operators to control the data flow.
- UI Updates: You can react to state changes via an Observable to update the UI, making it easier to implement complex business logic.
- Integration with Existing RxJS Code: Converting a Signal to an Observable allows it to be used with existing RxJS operators and code, maintaining consistency.
Converting a Signal to an Observable
import { Component, inject, Signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { QueryService } from './query.service';
@Component({/*...*/})
export class SearchResults {
http = inject(HttpClient);
query: Signal<string> = inject(QueryService).query; // Inject a Signal from QueryService
query$ = toObservable(this.query); // Convert the Signal to an Observable
results$ = this.query$.pipe(
switchMap(query => this.http.get('/search?q=' + query)) // Make an API request based on the query value
);
}
- Signal Injection: query is a Signal injected from QueryService.
- toObservable: query$ is the Observable version of the query Signal. It emits a new value every time query changes.
- Data Request: results$ subscribes to query$ and makes an API call whenever the query value changes. switchMap cancels the previous request and sends a new one with the latest query.
Usage Example after Conversion
import { signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
const mySignal = signal(0);
const obs$ = toObservable(mySignal); // Convert the Signal to an Observable
obs$.subscribe(value => console.log(value)); // Subscribe to the Observable
// Change the Signal's value
mySignal.set(1); // Outputs: 1
mySignal.set(2); // Outputs: 2
mySignal.set(3); // Outputs: 3
- Signal Creation: A Signal mySignal is created with an initial value of 0.
- toObservable: obs$ is the Observable converted from mySignal.
- Subscription: Subscribing to obs$ logs the value to the console whenever the Signal changes.
- Signal Value Change: Each call to mySignal.set() emits a new value to the subscriber, which is then logged.
Similarity to ReplaySubject
toObservable behaves similarly to a ReplaySubject(1). This means that when a new subscriber subscribes, it will immediately receive the most recent value emitted by the Signal. This is useful when the initial or current state of the Signal is needed upon subscription.
댓글남기기