6. Guided Project 1: Building a Signal-Driven Counter App
This project will guide you through building a slightly more complex counter application, leveraging writable, computed, and effect signals to manage its state and interactions. This will solidify your understanding of how signals work together in a practical scenario.
Project Objective: Create a counter application with multiple counters, customizable increments, reset functionality, and a history of changes, all driven by Angular Signals.
Project Setup
If you haven’t already, ensure your Angular development environment is set up as described in “Introduction to Modern Angular.”
Create a new Angular project:
ng new signal-counter-app --standalone --skip-tests --style=scss --routing false --no-strict cd signal-counter-appChoose
Nofor SSR/SSG andNofor Zoneless change detection for now, unless you want to explicitly practice that (it won’t affect the core signal logic here).Generate a new component for our main counter logic:
ng generate component counter-dashboard --standaloneUpdate
app.component.tsto display theCounterDashboardComponent:// src/app/app.component.ts import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CounterDashboardComponent } from './counter-dashboard/counter-dashboard.component'; // Import it @Component({ selector: 'app-root', standalone: true, imports: [CommonModule, CounterDashboardComponent], // Add to imports template: ` <main> <h1 style="text-align: center;">Signal-Driven Counter App</h1> <app-counter-dashboard></app-counter-dashboard> </main> `, styles: [` main { max-width: 800px; margin: 20px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); background-color: #fff; } `] }) export class AppComponent {}Run the application:
ng serve --openYou should see “Signal-Driven Counter App” and “counter-dashboard works!”
Step 1: Basic Writable Counter
Let’s start by implementing a simple counter with increment, decrement, and reset functionality using a writable signal.
counter-dashboard.component.ts
// src/app/counter-dashboard/counter-dashboard.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-counter-dashboard',
standalone: true,
imports: [CommonModule],
template: `
<div class="counter-card">
<h3>Current Count: {{ currentCount() }}</h3>
<div class="actions">
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset</button>
</div>
</div>
`,
styles: [`
.counter-card {
border: 1px solid #ccc;
padding: 20px;
margin: 20px;
border-radius: 8px;
text-align: center;
background-color: #f9f9f9;
}
.actions button {
margin: 0 5px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
border: 1px solid #007bff;
background-color: #007bff;
color: white;
border-radius: 5px;
}
.actions button:hover {
background-color: #0056b3;
border-color: #0056b3;
}
.actions button:active {
background-color: #004085;
border-color: #004085;
}
`]
})
export class CounterDashboardComponent {
currentCount = signal(0);
increment() {
this.currentCount.update(count => count + 1);
}
decrement() {
this.currentCount.update(count => count - 1);
}
reset() {
this.currentCount.set(0);
}
}
Verify in your browser: You should have a simple counter that updates when you click the buttons.
Step 2: Adding a Customizable Step and Computed Values
Let’s introduce an input to customize the increment/decrement step and add a computed signal to tell us if the count is even or odd.
counter-dashboard.component.ts (Update)
// src/app/counter-dashboard/counter-dashboard.component.ts (Update existing file)
import { Component, signal, computed } from '@angular/core'; // Import 'computed'
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; // Import FormsModule for ngModel
@Component({
selector: 'app-counter-dashboard',
standalone: true,
imports: [CommonModule, FormsModule], // Add FormsModule here
template: `
<div class="counter-card">
<h3>Current Count: {{ currentCount() }}</h3>
<p>Status: <strong [style.color]="isEven() ? 'green' : 'red'">{{ isEven() ? 'Even' : 'Odd' }}</strong></p>
<div class="controls">
<label for="stepInput">Step:</label>
<input id="stepInput" type="number" [(ngModel)]="stepValue" min="1" max="100">
<p class="current-step">Current Step: {{ currentStep() }}</p>
</div>
<div class="actions">
<button (click)="increment()">Increment by {{ currentStep() }}</button>
<button (click)="decrement()">Decrement by {{ currentStep() }}</button>
<button (click)="reset()">Reset</button>
</div>
</div>
`,
styles: [`
/* ... (existing styles) ... */
.controls {
margin-top: 15px;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.controls label {
font-weight: bold;
}
.controls input {
padding: 8px;
width: 60px;
text-align: center;
border: 1px solid #ccc;
border-radius: 4px;
}
.current-step {
font-size: 0.9em;
color: #555;
}
`]
})
export class CounterDashboardComponent {
currentCount = signal(0);
// Writable signal for the step value, initialized from a regular property
// We use a regular property for ngModel and then update the signal from it.
// In a real app, you might use Signal Forms for this input.
stepValue: number = 1;
currentStep = signal(1); // Signal to hold the actual step for reactivity
// Computed signal to determine if the count is even
isEven = computed(() => this.currentCount() % 2 === 0);
// Update stepValue via NgModel (or Signal Forms [control])
// We need to listen to changes on stepValue if we want currentStep to be reactive.
// For simplicity here, we'll update it on input blur or when buttons are pressed,
// or you could use a reactive approach for the input itself.
constructor() {
// When stepValue changes via ngModel, update the currentStep signal
// This is a simple way for now, a better way would be using Signal Forms [control] or (input)
// For NgModel with signals, you typically observe changes to the NgModel value.
// However, to keep it simple, we'll just read stepValue in increment/decrement and
// keep currentStep as a signal for template reading consistency.
}
increment() {
this.currentStep.set(this.stepValue); // Update currentStep signal
this.currentCount.update(count => count + this.currentStep());
}
decrement() {
this.currentStep.set(this.stepValue); // Update currentStep signal
this.currentCount.update(count => count - this.currentStep());
}
reset() {
this.currentCount.set(0);
}
}
Self-Correction/Improvement for stepValue and currentStep:
The current setup with [(ngModel)]="stepValue" and then manually updating this.currentStep.set(this.stepValue) in the increment/decrement methods is a bit clunky for reactive updates.
Better Approach for stepValue: Let’s use signal() directly for stepValue and bind it with (input) event. This makes it fully signal-driven.
counter-dashboard.component.ts (Refactored stepValue input)
// src/app/counter-dashboard/counter-dashboard.component.ts (Refactored)
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; // Still needed for other potential form controls
@Component({
selector: 'app-counter-dashboard',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="counter-card">
<h3>Current Count: {{ currentCount() }}</h3>
<p>Status: <strong [style.color]="isEven() ? 'green' : 'red'">{{ isEven() ? 'Even' : 'Odd' }}</strong></p>
<div class="controls">
<label for="stepInput">Step:</label>
<input id="stepInput"
type="number"
[value]="step() || 1" <!-- Bind value to signal, default to 1 if null -->
(input)="setStep($event)"
min="1" max="100">
<p class="current-step">Current Step: {{ step() }}</p>
</div>
<div class="actions">
<button (click)="increment()">Increment by {{ step() }}</button>
<button (click)="decrement()">Decrement by {{ step() }}</button>
<button (click)="reset()">Reset</button>
</div>
</div>
`,
styles: [`
/* ... (existing styles) ... */
.controls {
margin-top: 15px;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.controls label {
font-weight: bold;
}
.controls input {
padding: 8px;
width: 60px;
text-align: center;
border: 1px solid #ccc;
border-radius: 4px;
}
.current-step {
font-size: 0.9em;
color: #555;
}
`]
})
export class CounterDashboardComponent {
currentCount = signal(0);
step = signal(1); // Writable signal for the step value
isEven = computed(() => this.currentCount() % 2 === 0);
setStep(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value, 10);
this.step.set(isNaN(value) || value < 1 ? 1 : value);
}
increment() {
this.currentCount.update(count => count + this.step());
}
decrement() {
this.currentCount.update(count => count - this.step());
}
reset() {
this.currentCount.set(0);
}
}
Now, the step input is fully reactive. When you change the value in the input, step() updates, and the button labels also update instantly.
Step 3: Adding a Count History with an Effect
Let’s maintain a history of count changes using an array of numbers and update it with an effect whenever currentCount changes.
counter-dashboard.component.ts (Final Update)
// src/app/counter-dashboard/counter-dashboard.component.ts (Final update for Project 1)
import { Component, signal, computed, effect, Injector } from '@angular/core'; // Import 'effect' and 'Injector'
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-counter-dashboard',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="counter-card">
<h3>Current Count: {{ currentCount() }}</h3>
<p>Status: <strong [style.color]="isEven() ? 'green' : 'red'">{{ isEven() ? 'Even' : 'Odd' }}</strong></p>
<div class="controls">
<label for="stepInput">Step:</label>
<input id="stepInput"
type="number"
[value]="step() || 1"
(input)="setStep($event)"
min="1" max="100">
<p class="current-step">Current Step: {{ step() }}</p>
</div>
<div class="actions">
<button (click)="increment()">Increment by {{ step() }}</button>
<button (click)="decrement()">Decrement by {{ step() }}</button>
<button (click)="reset()">Reset</button>
</div>
<div class="history-card">
<h4>Count History (Last 5):</h4>
<ul>
@for (item of countHistory(); track $index) {
<li>{{ item }}</li>
} @empty {
<li>No history yet.</li>
}
</ul>
</div>
</div>
`,
styles: [`
/* ... (existing styles) ... */
.history-card {
border-top: 1px solid #eee;
margin-top: 25px;
padding-top: 20px;
text-align: left;
}
.history-card ul {
list-style-type: none;
padding: 0;
max-height: 150px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
}
.history-card li {
padding: 8px 15px;
border-bottom: 1px dashed #eee;
}
.history-card li:last-child {
border-bottom: none;
}
`]
})
export class CounterDashboardComponent {
currentCount = signal(0);
step = signal(1);
countHistory = signal<number[]>([]); // Writable signal for history
isEven = computed(() => this.currentCount() % 2 === 0);
constructor(private injector: Injector) {
// Effect to log count changes and update history
effect(() => {
const latestCount = this.currentCount(); // Reading the signal registers dependency
console.log(`[Effect] Current Count: ${latestCount}`);
// Update the history signal. Use update to avoid direct mutation and ensure reactivity.
// We only keep the last 5 entries.
this.countHistory.update(history => {
const newHistory = [...history, latestCount];
return newHistory.slice(-5); // Keep only the last 5 entries
});
}, { injector: this.injector }); // Provide injector if not creating directly in constructor
}
setStep(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value, 10);
this.step.set(isNaN(value) || value < 1 ? 1 : value);
}
increment() {
this.currentCount.update(count => count + this.step());
}
decrement() {
this.currentCount.update(count => count - this.step());
}
reset() {
this.currentCount.set(0);
this.countHistory.set([]); // Also clear history on reset
}
}
Now, when you interact with the counter, the countHistory list will update in real-time, displaying the last 5 values of currentCount. The effect ensures that whenever currentCount changes, the history is automatically managed.
Guided Project 1: Key Learnings
- Writable Signals: Used for
currentCount,step, andcountHistoryto manage direct, mutable state. - Computed Signals: Used for
isEvento derive state fromcurrentCountefficiently. - Effect Signals: Used to perform a side effect (updating
countHistoryand logging) whenevercurrentCountchanges, demonstrating how to react to signal updates for non-rendering logic. - Component Communication: We used the
(input)event to update thestepsignal from a native input, showcasing how signals can interact with standard HTML elements. - Template Directives (
@for,[style.color]): How to integrate signals smoothly with Angular’s template syntax.
Exercises/Mini-Challenges for Project 1
LocalStorage Persistence for Count:
- Modify
CounterDashboardComponentto savecurrentCount()tolocalStorageusing anothereffect. - When the component initializes, read the initial
currentCountfromlocalStorageif it exists. - Hint: You’ll need two effects: one for loading, one for saving. The loading should happen in the constructor (or
ngOnInit), and the saving in aneffect.
- Modify
Max Count Limit:
- Add a writable signal
maxCount(e.g.,signal(20)). - Modify the
increment()method so thatcurrentCountcannot exceedmaxCount. - Change the “Increment” button’s
disabledstate based on whethercurrentCounthas reachedmaxCount. - Challenge: Add an input field to let the user set the
maxCount.
- Add a writable signal
Undo/Redo Functionality (Advanced):
- Instead of just keeping the last 5 entries, implement a more robust
undomechanism. - Maintain two history signals:
pastCounts: number[]andfutureCounts: number[]. - When
currentCountchanges, push the oldcurrentCounttopastCountsand clearfutureCounts. - Add an “Undo” button that, when clicked, restores the last
currentCountfrompastCounts(and moves the current count tofutureCounts). - Add a “Redo” button that reverts an undo.
- Hint: This is a complex state management problem. Think carefully about how
undoandredooperations affect bothcurrentCountand the history signals. You might need to temporarily disable the historyeffectduring undo/redo operations to prevent infinite loops or unintended history entries.
- Instead of just keeping the last 5 entries, implement a more robust
This project provided a hands-on experience with Signals in a common application pattern. Continue to experiment with these challenges to deepen your understanding.