Share State Between Components with an Angular Service

Angular Tutorial: Beginner to Advanced · 7. Services and dependency injection · 4 min read · Updated

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, toggle and remove, 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

  1. Add a clearCompleted() method and a button that uses it.
  2. Add a computed completed count and show it.
  3. Create a third component, a small badge in a header, that shows remaining().

Quick check

  1. Why does the service expose asReadonly()?
  2. How do two components share the same tasks?
  3. Where should the rules for changing tasks live?

Next, continue with Angular Hierarchical Dependency Injection: Providers, @Self, @SkipSelf, @Optional, @Host.

Frequently asked questions

Do I need NgRx or another state library?

Not for the apps in this course. Signals in services are enough for most small and medium apps. Reach for a library when you have complex shared state and a team that wants strict patterns.

Is it a problem to keep state in a service forever?

The app-wide instance lives as long as the app does. If state should reset when a screen closes, provide the service on that component instead.