When two components that are not parent and child need the same data, passing inputs and outputs around gets awkward. A service holding signals solves this neatly: both components read from the service, so they always agree.
What you will learn
- How to keep state in a service with signals
- How to expose read-only state and methods that change it
- How two unrelated components stay in sync
The data model
You can reuse the task model from the control flow lessons:
// src/app/task.model.ts
export interface Task {
id: number;
title: string;
done: boolean;
}
The service
The service owns the data. It keeps a private writable signal and offers a read-only view plus methods for every change that is allowed:
// src/app/task-store.ts
import { Service, computed, signal } from '@angular/core';
import { Task } from './task.model';
@Service()
export class TaskStore {
private nextId = 1;
private readonly _tasks = signal<Task[]>([]);
// read-only view for components
readonly tasks = this._tasks.asReadonly();
readonly remaining = computed(() => this._tasks().filter((task) => !task.done).length);
add(title: string): void {
const clean = title.trim();
if (!clean) {
return;
}
this._tasks.update((list) => [...list, { id: this.nextId++, title: clean, done: false }]);
}
toggle(id: number): void {
this._tasks.update((list) => list.map((task) => (task.id === id ? { ...task, done: !task.done } : task)));
}
remove(id: number): void {
this._tasks.update((list) => list.filter((task) => task.id !== id));
}
}
asReadonly() returns a version of the signal that components can read but not set. All changes have to go through the methods, so the rules for changing tasks live in one place.
Component one: add tasks
// src/app/task-form/task-form.ts
import { Component, inject } from '@angular/core';
import { TaskStore } from '../task-store';
@Component({
selector: 'app-task-form',
template: `
<input #title placeholder="New task" (keyup.enter)="add(title)" />
<button type="button" (click)="add(title)">Add</button>
`,
})
export class TaskForm {
private readonly store = inject(TaskStore);
protected add(input: HTMLInputElement): void {
this.store.add(input.value);
input.value = '';
input.focus();
}
}
Component two: show tasks
// src/app/task-board/task-board.ts
import { Component, inject } from '@angular/core';
import { TaskStore } from '../task-store';
@Component({
selector: 'app-task-board',
template: `
<p>{{ store.remaining() }} of {{ store.tasks().length }} left</p>
<ul>
@for (task of store.tasks(); track task.id) {
<li>
<label>
<input type="checkbox" [checked]="task.done" (change)="store.toggle(task.id)" />
<span [style.text-decoration]="task.done ? 'line-through' : 'none'">{{ task.title }}</span>
</label>
<button type="button" (click)="store.remove(task.id)" [attr.aria-label]="'Remove ' + task.title">Remove</button>
</li>
} @empty {
<li>No tasks yet.</li>
}
</ul>
`,
})
export class TaskBoard {
protected readonly store = inject(TaskStore);
}
Use both together
// src/app/app.ts
import { Component } from '@angular/core';
import { TaskBoard } from './task-board/task-board';
import { TaskForm } from './task-form/task-form';
@Component({
imports: [TaskForm, TaskBoard],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Shared state</h1>
<app-task-form />
<app-task-board />
</main>
Type a task and press Enter. The form adds it through the service, and the board, which is a separate component, updates at once. Neither component knows about the other. They only know the store.
Why this pattern works
- One source of truth. The data lives in one place, so two screens cannot disagree.
- Controlled changes. Components cannot set the signal directly. They call
add,toggleandremove, so the rules stay in one place. - Easy to test. You can test the service without any component.
- Easy to grow. When you later load tasks from a server, only the service changes.
Try it yourself
- Add a
clearCompleted()method and a button that uses it. - Add a computed
completedcount and show it. - Create a third component, a small badge in a header, that shows
remaining().
Quick check
- Why does the service expose
asReadonly()? - How do two components share the same tasks?
- Where should the rules for changing tasks live?
Next, continue with Angular Hierarchical Dependency Injection: Providers, @Self, @SkipSelf, @Optional, @Host.