Telerik blogs

A comprehensive tutorial for experienced Angular developers to learn Angular Signals by building a practical countdown timer application with computed signals and effects.

Angular Signals represent a fundamental shift in how we manage reactive state in Angular applications. If you’re coming from RxJS observables or other state management libraries, signals offer a more intuitive, performant and granular approach to reactivity. Unlike the zone-based change detection that runs for entire component trees, signals provide fine-grained reactivity that updates only what actually changed.

In this tutorial, we’ll build a practical countdown timer application that demonstrates the core concepts of Angular Signals. You’ll learn how signals provide automatic dependency tracking, eliminate the need for manual subscriptions and create more predictable reactive applications.

Example Overview: Reactive Countdown Timer

A countdown timer is an excellent example for learning signals because it involves multiple reactive states that depend on each other. The timer will demonstrate how signals naturally handle:

  • State management: Timer duration, current time and running state
  • Derived state: Formatted time display and progress percentage
  • Side effects: Completion alerts and UI updates

Real-world applications for this pattern include:

  • Recording studios: Session time tracking and break timers
  • Live events: Speaker time limits and presentation countdowns
  • Productivity apps: Pomodoro timers and focus sessions
  • Fitness apps: Workout intervals and rest periods

It will feature start/stop/reset controls, preset time buttons, visual progress indicators and completion messages. All built with signals to showcase their reactive capabilities.

Setting Up the Project

Let’s start by creating a new Angular project with the latest version that includes signals support:

npm install -g @angular/cli
ng new countdown-timer --routing=false --style=css
cd countdown-timer

You could use npx if you don’t want to install Angular CLI globally: npx -p @angular/cli@20 ng new countdown-timer --routing=false --style=css.

Create the timer component:

ng generate component countdown-timer --standalone

Update src/app/app.ts to import and use the countdown timer component:

import { CountdownTimer } from "./countdown-timer/countdown-timer";

@Component({
  // rest of the component metadata
  imports: [CountdownTimer],
})

Update src/app/app.html to render the new component:

<div class="app-container">
  <h1>Angular Signals Countdown Timer</h1>
  <app-countdown-timer></app-countdown-timer>
</div>

Add some basic styling to src/app/app.css:

.app-container {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}

h1 {
  color: #2c3e50;
  margin-bottom: 2rem;
}

Basic Angular app with the countdown timer component placeholder

Angular Signals Fundamentals

Signals are reactive primitives that hold values and notify consumers when those values change. Let’s start by implementing the core timer state in the CountdownTimer component. Copy and paste the code below into src/app/countdown-timer/countdown-timer.ts file:

import { Component, signal, computed, effect, OnDestroy } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-countdown-timer",
  standalone: true,
  imports: [CommonModule],
  templateUrl: "./countdown-timer.html",
  styleUrl: "./countdown-timer.css",
})
export class CountdownTimer implements OnDestroy {
  // Writable signals for managing timer state
  private timeRemaining = signal(60); // seconds
  private isRunning = signal(false);
  private initialTime = signal(60);

  // Expose read-only versions for the template
  readonly timeLeft = this.timeRemaining.asReadonly();
  readonly running = this.isRunning.asReadonly();

  private intervalId: number | null = null;

  constructor() {
    console.log("Timer initialized with:", this.timeLeft());
  }

  ngOnDestroy(): void {
    // Clean up interval when component is destroyed
    this.stop();
  }
}

The code creates a writable signal with an initial value using the syntax signal(initialValue). Writable signals provide an API for updating their values directly. Afterward, it creates read-only versions of those signals for use in the template.

It keeps a reference to the intervalId to make sure the timer is cleaned up properly. You may have noticed that, unlike observables, signals don’t require explicit subscriptions. It keeps track of where and how they’re used and updates them automatically.

Building the Core Timer Logic

Now let’s implement the timer functionality with signal-based state management. Add the following code to the CountdownTimer component:

export class CountdownTimer {
  // ... previous code ...

  // Timer control methods
  start(): void {
    if (this.isRunning()) return;

    this.isRunning.set(true);

    this.intervalId = window.setInterval(() => {
      this.timeRemaining.update((time) => {
        if (time <= 1) {
          this.stop();
          return 0;
        }
        return time - 1;
      });
    }, 1000);
  }

  stop(): void {
    this.isRunning.set(false);
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  reset(): void {
    this.stop();
    this.timeRemaining.set(this.initialTime());
  }

  setTime(seconds: number): void {
    this.stop();
    this.initialTime.set(seconds);
    this.timeRemaining.set(seconds);
  }
}

The set() and update() methods change the value of a writable signal. The difference between them is that the .update() method receives the current value and returns the new value. This functional approach enables immutability and makes state changes predictable.

Computed Signals

Computed signals derive their values from other signals and automatically recalculate when dependencies change. Add these derived states to the component:

export class CountdownTimer implements OnDestroy {
  // ... previous code ...

  // Computed signals for derived state
  readonly formattedTime = computed(() => {
    const time = this.timeLeft();
    const minutes = Math.floor(time / 60);
    const seconds = time % 60;
    return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
  });

  readonly progressPercentage = computed(() => {
    const initial = this.initialTime();
    const remaining = this.timeLeft();
    if (initial === 0) return 0;
    return ((initial - remaining) / initial) * 100;
  });

  readonly isCompleted = computed(() => this.timeLeft() === 0);

  readonly buttonText = computed(() => (this.running() ? "Pause" : "Start"));
}

Computed signals are read-only and lazily evaluated—they only recalculate when accessed and when their dependencies change. This is more efficient than manually managing derived state with observables.

Signal Effects

Effects are asynchronous operations that run when one or more signals change. They’re useful for tasks like logging, analytics or adding custom DOM behavior that can’t be expressed with template syntax. Add the following effects to log info when the countdown completes:

export class CountdownTimer implements OnDestroy {
  // ... previous code ...

  constructor() {
    // Effect for logging state changes (useful for debugging)
    effect(() => {
      console.log(
        `Timer state: ${this.formattedTime()}, Running: ${this.running()}`,
      );
    });

    // Effect for completion handling
    effect(() => {
      if (this.isCompleted()) {
        // Trigger completion event or notification
        this.onTimerComplete();
      }
    });
  }
  // Handle timer completion
  private onTimerComplete(): void {
    // In a real app, emit an event, show toast notification, play sound, etc.
    // This makes the code testable and follows Angular best practices
    console.log("Timer has completed - handle completion here");
  }

  ngOnDestroy(): void {
    // Clean up interval when component is destroyed
    this.stop();
  }
}

Effects automatically track their signal dependencies and rerun when any dependency changes. Unlike RxJS subscriptions, you don’t need to manually unsubscribe because Angular handles cleanup automatically when the component is destroyed.

Important: Avoid using effects for propagation of state changes. Use computed signals instead. Effects should only be used for side effects like DOM manipulation that can’t be expressed with template syntax.

Visualizing the Timer

It’s time to show you how to use signals to build reactive user interfaces. Let’s add preset time buttons and a visual progress indicator. Update your template (countdown-timer.html):

<div class="timer-container">
  <!-- Time Display -->
  <div class="time-display">{{ formattedTime() }}</div>

  <!-- Progress Bar -->
  <div class="progress-container">
    <div class="progress-bar" [style.width.%]="progressPercentage()"></div>
  </div>

  <!-- Control Buttons -->
  <div class="controls">
    <button (click)="running() ? stop() : start()" [disabled]="isCompleted()">
      {{ buttonText() }}
    </button>

    <button (click)="reset()">Reset</button>
  </div>

  <!-- Preset Time Buttons -->
  <div class="presets">
    <button (click)="setTime(30)" [disabled]="running()">30s</button>
    <button (click)="setTime(60)" [disabled]="running()">1min</button>
    <button (click)="setTime(300)" [disabled]="running()">5min</button>
    <button (click)="setTime(600)" [disabled]="running()">10min</button>
  </div>
</div>

Add the corresponding styles (countdown-timer.css):

.timer-container {
  background: #f8f9fa;
  border-radius: 12px;
  padding: 2rem;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.time-display {
  font-size: 4rem;
  font-weight: bold;
  color: #2c3e50;
  margin-bottom: 1.5rem;
  font-family: "Courier New", monospace;
}

.progress-container {
  width: 100%;
  height: 8px;
  background-color: #e9ecef;
  border-radius: 4px;
  margin-bottom: 2rem;
  overflow: hidden;
}

.progress-bar {
  height: 100%;
  background: linear-gradient(90deg, #28a745, #ffc107, #dc3545);
  transition: width 0.3s ease;
}

.controls,
.presets {
  display: flex;
  gap: 1rem;
  justify-content: center;
  margin-bottom: 1rem;
}

button {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 6px;
  background: #007bff;
  color: white;
  cursor: pointer;
  font-weight: 500;
  transition: background-color 0.2s;
}

button:hover:not(:disabled) {
  background: #0056b3;
}

button:disabled {
  background: #6c757d;
  cursor: not-allowed;
}

Notice how the template directly uses signal values with function call syntax: formattedTime(), progressPercentage(), running(). Angular’s template engine automatically tracks these dependencies and updates only the affected DOM nodes.


Signal vs. RxJS Observables

Signals provide several benefits over the former observable-based approach:

// Traditional approach with observables
export class TraditionalComponent {
  private timeSubject = new BehaviorSubject(60);
  time$ = this.timeSubject.asObservable();

  formattedTime$ = this.time$.pipe(
    map((time) => {
      const minutes = Math.floor(time / 60);
      const seconds = time % 60;
      return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
    }),
  );

  ngOnDestroy() {
    // Manual subscription management required
    this.timeSubject.complete();
  }
}

// Signal approach
export class SignalComponent {
  private timeRemaining = signal(60);

  readonly formattedTime = computed(() => {
    const time = this.timeRemaining();
    const minutes = Math.floor(time / 60);
    const seconds = time % 60;
    return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
  });

  // No manual cleanup needed
}

Key benefits:

  • Granular updates: Only components using changed signals rerender
  • No subscription management: Automatic dependency tracking and cleanup
  • No async pipe complexity in templates
  • Tree-shakable: Unused computations are automatically optimized away

Best Practices

1. Use Readonly Signals for Public APIs

private _count = signal(0);
readonly count = this._count.asReadonly();

2. Prefer Computed Signals over Manual Derivation

// ✅ Good
readonly isEven = computed(() => this.count() % 2 === 0);

// ❌ Avoid
get isEven() { return this.count() % 2 === 0; }

3. Use Effects Sparingly for Side Effects Only

// ✅ Good - logging, localStorage, DOM manipulation
effect(() => {
  localStorage.setItem("timerState", JSON.stringify(this.timerState()));
});

effect(() => {
  console.log(`Timer state changed: ${this.formattedTime()}`);
});

// ❌ Avoid - direct UI interactions in effects
effect(() => {
  if (this.isCompleted()) {
    alert("Done!"); // Better to emit events or call methods
  }
});

4. Implement Proper Cleanup for Components with Intervals/Timers

export class TimerComponent implements OnDestroy {
  ngOnDestroy(): void {
    this.stop(); // Clean up intervals
  }
}

5. Keep Signal Updates Simple and Predictable

// ✅ Good
this.count.update((n) => n + 1);

// ❌ Avoid complex logic in updates
this.count.update((n) => {
  // Complex business logic here...
  return someComplexCalculation(n);
});

Conclusion

Angular Signals represent a paradigm shift toward more intuitive and performant reactive programming. Through building this countdown timer, you’ve learned its core concepts and best practices.

The timer demonstrates how signals naturally handle complex reactive scenarios with writable signals, computed signals and effects. The declarative nature of computed signals and the automatic dependency tracking make your code more predictable and easier to reason about.

Follow these steps to adopt signals in your production applications:

  1. Start small: Convert simple reactive state from observables to signals
  2. Identify computed values: Look for derived state that can become computed signals
  3. Migrate gradually: Signals interop well with observables during transition
  4. Leverage effects: Replace subscription-based side effects with signal effects

As Angular continues to evolve, signals will become increasingly central to the framework’s reactive model.

Additional Resources


Peter Mbanugo
About the Author

Peter Mbanugo

Peter is a software consultant, technical trainer and OSS contributor/maintainer with excellent interpersonal and motivational abilities to develop collaborative relationships among high-functioning teams. He focuses on cloud-native architectures, serverless, continuous deployment/delivery, and developer experience. You can follow him on Twitter.

Related Posts

Comments

Comments are disabled in preview mode.