Abelfubu logo

Global State with Angular Signals and the Power of Dependency Injection

Abel de la Fuente
Abel de la Fuente 13 min read -
Global State with Angular Signals and the Power of Dependency Injection
Photo by Andriyko Podilnyk on Unsplash

Introduction

In Angular, signals are a great tool for state management and handling synchronous data. They aren’t limited to components; we can also use them in services to manage global state. This article demonstrates how to leverage signals and dependency injection (DI) in Angular to create a reusable global state management system.

Using Signals in Components

Let’s start with a simple example of a counter component that uses signals to manage its state.

1
@Component({
2
selector: "app-counter",
3
template: `
4
<h1>Counter</h1>
5
<button (click)="increment()">Increment</button>
6
<p>Count: {{ count() }}</p>
7
<button (click)="decrement()">Decrement</button>
8
`,
9
})
10
export class CounterComponent {
11
protected readonly count = signal(0);
12
13
increment(): void {
14
this.count.update((count) => count + 1);
15
}
16
17
decrement(): void {
18
this.count.update((count) => count - 1);
19
}
20
}

In this example, we have a simple counter with increment and decrement functionality. However, this state is local to the component. Let’s move this state to a service to manage it globally.

Moving State to an Injectable Service

We’ll create a service to handle the global state of the counter.

1
@Component({
2
selector: "app-counter",
3
template: `
4
<h1>Counter</h1>
5
<button (click)="counter.increment()">Increment</button>
6
<p>Count: {{ counter.count() }}</p>
7
<button (click)="counter.decrement()">Decrement</button>
8
`,
9
})
10
export class CounterComponent {
11
protected readonly counter = inject(Counter);
12
}
13
14
@Injectable({
15
providedIn: "root",
16
})
17
export class Counter {
18
private readonly state = signal(0);
19
readonly count = computed(() => this.state());
20
21
increment(): void {
22
this.count.update((count) => count + 1);
23
}
24
25
decrement(): void {
26
this.count.update((count) => count - 1);
27
}
28
}

By moving the state to an injectable service, we make it reusable across different components. This is similar to custom hooks in other frameworks, allowing us to encapsulate and reuse our application logic.

Functional Approach to State Management

If you prefer a more functional approach, you can create a function to handle the state.

1
export function createCounter(initialValue: number): ... {
2
const count = signal(initialValue);
3
4
return {
5
count: computed(() => count()),
6
increment: count.update(count => count + 1),
7
decrement: count.update(count => count - 1),
8
}
9
}
10
11
@Component({
12
selector: 'app-counter',
13
template: `
14
<h1>Counter</h1>
15
<button (click)="counter.increment()">Increment</button>
16
<p>Count: {{ counter.count() }}</p>
17
<button (click)="counter.decrement()">Decrement</button>
18
`,
19
})
20
export class CounterComponent {
21
protected readonly counter = createCounter(0);
22
}

This approach works the same way but lacks the benefits of DI, such as easily mocking dependencies and decoupling our code.

Using Injection Tokens for State Management

We can combine the functional approach with DI by creating the counter as an injection token.

1
const injectCounter = (initialState: number) => {
2
return inject(new InjectionToken('appCounter', {
3
providedIn: 'root',
4
factory: () => {
5
const state = signal(initialState);
6
7
return {
8
count: state,
9
increment: () => state.update((count) => count + 1),
10
decrement: () => state.update((count) => count - 1),
11
};
12
},
13
}));
14
};
15
16
@Component({...})
17
export class CounterComponent {
18
protected readonly counter = injectCounter(0);
19
}

Adding More Complexity with Dependency Injection

Of course, you can get creative and inject additional dependencies to create more complex state management solutions. For example, you might want to fetch the counter value from an API:

1
const injectCounter = (initialState: number) => {
2
const http = inject(HttpClient);
3
4
return inject(
5
new InjectionToken('appCounter', {
6
providedIn: 'root',
7
factory: () => {
8
const state = signal(initialState);
9
10
return {
11
count: state,
12
increment: () => state.update((count) => count + 1),
13
decrement: () => state.update((count) => count - 1),
14
getCount: () =>
15
http
16
.get<number>('https://counter.api.com')
17
.pipe(tap((count) => state.set(count))),
18
};
19
},
20
}),
21
);
22
};

By injecting HttpClient, we can now fetch the initial counter value from an external API, further demonstrating the flexibility and power of combining signals with DI. Of course you can get creative here and inject some other deps to create a more complex state management

Conclusion

Using Angular signals and DI together allows us to create flexible, reusable, and decoupled global state management solutions. Whether you prefer a class-based or functional approach, leveraging these powerful features of Angular can significantly improve your application’s architecture.