Angular Signals: Building a Reactive Countdown Timer

Published: August 28, 2025 8 min read Web, Angular 0 Comments

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


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.
Please enable JavaScript to view the comments powered by Disqus.

All articles

Topics
Web
Web
Mobile
Mobile
Desktop
Desktop
Design
Design
Productivity
Productivity
People
People
AI
Latest Stories
in Your Inbox

Subscribe to be the first to get our expert-written articles and tutorials for developers!

All fields are required

Loading animation

Progress collects the Personal Information set out in our Privacy Policy and the Supplemental Privacy notice for residents of California and other US States and uses it for the purposes stated in that policy.

You can also ask us not to share your Personal Information to third parties here: Do Not Sell or Share My Info

By submitting this form, you understand and agree that your personal data will be processed by Progress Software or its Partners as described in our Privacy Policy. You may opt out from marketing communication at any time here or through the opt out option placed in the e-mail communication sent by us or our Partners.

We see that you have already chosen to receive marketing materials from us. If you wish to change this at any time you may do so by clicking here.

Thank you for your continued interest in Progress. Based on either your previous activity on our websites or our ongoing relationship, we will keep you updated on our products, solutions, services, company news and events. If you decide that you want to be removed from our mailing lists at any time, you can change your contact preferences by clicking here.

[フレーム]

AltStyle によって変換されたページ (->オリジナル) /