Guided Project 1: Building a Signal-Driven Counter App

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.”

  1. Create a new Angular project:

    ng new signal-counter-app --standalone --skip-tests --style=scss --routing false --no-strict
    cd signal-counter-app
    

    Choose No for SSR/SSG and No for Zoneless change detection for now, unless you want to explicitly practice that (it won’t affect the core signal logic here).

  2. Generate a new component for our main counter logic:

    ng generate component counter-dashboard --standalone
    
  3. Update app.component.ts to display the CounterDashboardComponent:

    // 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 {}
    
  4. Run the application:

    ng serve --open
    

    You 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, and countHistory to manage direct, mutable state.
  • Computed Signals: Used for isEven to derive state from currentCount efficiently.
  • Effect Signals: Used to perform a side effect (updating countHistory and logging) whenever currentCount changes, demonstrating how to react to signal updates for non-rendering logic.
  • Component Communication: We used the (input) event to update the step signal 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

  1. LocalStorage Persistence for Count:

    • Modify CounterDashboardComponent to save currentCount() to localStorage using another effect.
    • When the component initializes, read the initial currentCount from localStorage if 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 an effect.
  2. Max Count Limit:

    • Add a writable signal maxCount (e.g., signal(20)).
    • Modify the increment() method so that currentCount cannot exceed maxCount.
    • Change the “Increment” button’s disabled state based on whether currentCount has reached maxCount.
    • Challenge: Add an input field to let the user set the maxCount.
  3. Undo/Redo Functionality (Advanced):

    • Instead of just keeping the last 5 entries, implement a more robust undo mechanism.
    • Maintain two history signals: pastCounts: number[] and futureCounts: number[].
    • When currentCount changes, push the old currentCount to pastCounts and clear futureCounts.
    • Add an “Undo” button that, when clicked, restores the last currentCount from pastCounts (and moves the current count to futureCounts).
    • Add a “Redo” button that reverts an undo.
    • Hint: This is a complex state management problem. Think carefully about how undo and redo operations affect both currentCount and the history signals. You might need to temporarily disable the history effect during undo/redo operations to prevent infinite loops or unintended history entries.

This project provided a hands-on experience with Signals in a common application pattern. Continue to experiment with these challenges to deepen your understanding.