Skip to main content

Overview

Happy Habitat uses Angular Signals as the primary state management solution. This modern, reactive approach provides fine-grained reactivity without the complexity of external state management libraries like NgRx or Redux.

State Management Architecture

Why Signals?

The application uses Angular Signals (introduced in Angular 16+) for several reasons:
  • Fine-grained reactivity - Updates only affected components
  • Simple API - Easy to learn and use
  • Built-in - No external dependencies
  • Type-safe - Full TypeScript support
  • Performance - Efficient change detection
  • Composable - Easy to create derived state with computed()

State Distribution

State is distributed across services following the Single Responsibility Principle:
AuthService          → Authentication state (user, tokens, auth status)
UsersService         → Current user information
SessionService       → Session storage and persistence
ErrorService         → Active errors and notifications
LoggerService        → Critical errors log
CommunityService     → Selected community

Using Signals

Creating Signals

Signals are created in services using the signal() function:
import { signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class AuthService {
  // Writable signals
  isAuthenticated = signal<boolean>(false);
  currentUser = signal<UserInfo | null>(null);
  isLoading = signal<boolean>(false);
  
  // Update signals
  login(credentials: LoginRequest): Observable<AuthResponse> {
    this.isLoading.set(true);
    return this.http.post<AuthResponse>(url, credentials).pipe(
      tap((response) => {
        this.isAuthenticated.set(true);
        this.currentUser.set(response.user);
        this.isLoading.set(false);
      })
    );
  }
}

Reading Signals in Components

import { Component, inject, computed } from '@angular/core';
import { AuthService } from './services/auth.service';

@Component({
  selector: 'app-header',
  template: `
    <div *ngIf="isLoggedIn()">
      <p>Welcome, {{ userName() }}!</p>
      <p *ngIf="isAdmin()">Admin Panel</p>
    </div>
    <div *ngIf="isLoading()">
      Loading...
    </div>
  `
})
export class HeaderComponent {
  authService = inject(AuthService);
  
  // Read signals directly
  isLoggedIn = this.authService.isAuthenticated;
  userName = computed(() => this.authService.currentUser()?.fullname || 'Guest');
  isLoading = this.authService.isLoading;
  
  // Computed signals
  isAdmin = computed(() => 
    this.authService.currentUser()?.selectedRole === RolesEnum.SYSTEM_ADMIN
  );
}

Computed Signals

Derived state using computed():
export class DashboardComponent {
  authService = inject(AuthService);
  
  // Automatically updates when currentUser changes
  userDisplayName = computed(() => {
    const user = this.authService.currentUser();
    return user ? `${user.fullname} (${user.selectedRole})` : 'Guest';
  });
  
  canEditResidents = computed(() => {
    const user = this.authService.currentUser();
    return user?.selectedRole === RolesEnum.SYSTEM_ADMIN || 
           user?.selectedRole === RolesEnum.ADMIN_COMPANY;
  });
}

Updating Signals

Direct Update:
this.isAuthenticated.set(true);
this.currentUser.set(userData);
Update with Function:
// Update based on current value
this.activeErrors.update(errors => [...errors, newError]);

// Remove from array
this.activeErrors.update(errors => 
  errors.filter(err => err.id !== errorId)
);

Service-Based State Management

AuthService State

Location: app/services/auth.service.ts:1
export class AuthService {
  // Authentication state
  isAuthenticated = signal<boolean>(false);
  currentUser = signal<UserInfo | null>(null);
  isLoading = signal<boolean>(false);
  pendingLoginResponse = signal<{ 
    loginResponse: LoginResponse; 
    authResponse: AuthResponse 
  } | null>(null);
}
Usage:
// Component
export class NavComponent {
  authService = inject(AuthService);
  
  // Template binding
  isLoggedIn = this.authService.isAuthenticated;
  user = this.authService.currentUser;
}

UsersService State

Location: app/services/users.service.ts:1
export class UsersService {
  // Current user signal
  currentUser = signal<UserInfo | null>(null);
  
  constructor() {
    this.initializeUserFromSession();
  }
  
  setCurrentUser(user: UserInfo): void {
    this.currentUser.set(user);
  }
  
  getCurrentUser(): UserInfo | null {
    return this.currentUser();
  }
}

ErrorService State

Location: app/services/error.service.ts:1
export class ErrorService {
  // Active errors tracking
  activeErrors = signal<ErrorNotification[]>([]);
  
  addActiveError(error: AppError): void {
    const notification: ErrorNotification = {
      id: this.generateErrorId(),
      error,
      timestamp: new Date(),
      dismissed: false
    };
    
    this.activeErrors.update(errors => 
      [notification, ...errors].slice(0, 20) // Keep last 20
    );
  }
  
  dismissError(errorId: string): void {
    this.activeErrors.update(errors =>
      errors.map(err => 
        err.id === errorId ? { ...err, dismissed: true } : err
      )
    );
  }
}

LoggerService State

Location: app/services/logger.service.ts:1
export class LoggerService {
  // Critical errors tracking
  criticalErrors = signal<LogEntry[]>([]);
  
  error(message: string, error?: Error, context?: LogContext, data?: any): void {
    const entry = this.createLogEntry(LogLevel.ERROR, message, context, data);
    
    // Add to critical errors
    this.criticalErrors.update(errors => 
      [entry, ...errors].slice(0, 50) // Keep last 50
    );
  }
}

State Synchronization

Cross-Service State Sync

The application synchronizes state between services when needed:
// AuthService handles authentication success
private handleAuthSuccess(response: AuthResponse): void {
  // Update AuthService state
  this.isAuthenticated.set(true);
  this.currentUser.set(response.user);
  
  // Sync with SessionService
  this.sessionService.saveSession(response);
  
  // Sync with UsersService
  this.usersService.setCurrentUser(response.user);
}

Session Persistence

State is persisted to localStorage for session continuity:
// Save to localStorage
this.sessionService.saveSession(authResponse);

// Restore on initialization
constructor() {
  this.checkStoredSession();
}

private checkStoredSession(): void {
  const user = this.sessionService.getUser();
  if (user) {
    this.currentUser.set(user);
    this.isAuthenticated.set(true);
  }
}

Patterns and Best Practices

1. Service-Based State

Keep state in services, not components:
// Good - State in service
@Injectable({ providedIn: 'root' })
export class AuthService {
  currentUser = signal<UserInfo | null>(null);
}

// Avoid - State in component
export class MyComponent {
  currentUser = signal<UserInfo | null>(null); // Don't do this for shared state
}

2. Computed for Derived State

Use computed() instead of manual updates:
// Good - Computed signal
userDisplayName = computed(() => {
  const user = this.authService.currentUser();
  return user?.fullname || 'Guest';
});

// Avoid - Manual updates
userDisplayName = '';
this.authService.currentUser.subscribe(user => {
  this.userDisplayName = user?.fullname || 'Guest';
});

3. Effect for Side Effects

Use effect() for side effects that depend on signals:
import { effect } from '@angular/core';

export class MyComponent {
  constructor() {
    // Run side effect when signal changes
    effect(() => {
      const user = this.authService.currentUser();
      if (user) {
        this.logger.info('User logged in', 'MyComponent', { userId: user.id });
      }
    });
  }
}

4. Read-Only Signals

Expose read-only versions for external access:
export class MyService {
  // Private writable signal
  private _state = signal<State>({ ... });
  
  // Public read-only signal
  readonly state = this._state.asReadonly();
  
  // Update methods
  updateState(newState: State): void {
    this._state.set(newState);
  }
}

5. Signal Naming

Follow consistent naming conventions:
// State signals (nouns)
currentUser = signal<UserInfo | null>(null);
activeErrors = signal<ErrorNotification[]>([]);

// Status signals (adjectives/booleans)
isAuthenticated = signal<boolean>(false);
isLoading = signal<boolean>(false);

// Computed signals (descriptive)
userDisplayName = computed(() => ...);
canEditResidents = computed(() => ...);

Local Storage Strategy

Cache Implementation

Some services use localStorage for caching:
// ResidentsService example
private readonly CACHE_KEY = 'hh_residents_community_';
private readonly CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes

// Save to cache
private setCache(key: string, data: Data[]): void {
  localStorage.setItem(key, JSON.stringify({ 
    data, 
    savedAt: Date.now() 
  }));
}

// Read from cache
private getFromCache(key: string): Data[] | null {
  const raw = localStorage.getItem(key);
  if (!raw) return null;
  
  const parsed = JSON.parse(raw);
  const expired = Date.now() - parsed.savedAt > this.CACHE_TTL_MS;
  
  return expired ? null : parsed.data;
}

Storage Keys Convention

// Authentication
const TOKEN_KEY = 'hh_token';
const REFRESH_TOKEN_KEY = 'hh_refresh_token';
const USER_KEY = 'hh_user';

// Cache
const CACHE_KEY_PREFIX = 'hh_residents_community_';
const CACHE_KEY_PAGED_PREFIX = 'hh_residents_community_paged_';
All keys use the hh_ prefix to avoid conflicts.

Migration from Other State Management

If you’re familiar with other state management solutions:

From Redux/NgRx

Redux/NgRxSignals
StoreService with signals
Selectorcomputed()
ActionService method
Reducersignal.update()
Effecteffect() or service method

From RxJS BehaviorSubject

// Before - BehaviorSubject
private currentUserSubject = new BehaviorSubject<UserInfo | null>(null);
currentUser$ = this.currentUserSubject.asObservable();

setCurrentUser(user: UserInfo): void {
  this.currentUserSubject.next(user);
}

// After - Signal
currentUser = signal<UserInfo | null>(null);

setCurrentUser(user: UserInfo): void {
  this.currentUser.set(user);
}

Performance Considerations

Change Detection

Signals enable precise change detection:
  • Components only update when their specific signals change
  • No need for ChangeDetectorRef.markForCheck()
  • No need for OnPush strategy with signals

Computed Memoization

Computed signals are automatically memoized:
// Only recomputes when dependencies change
userDisplayName = computed(() => {
  const user = this.authService.currentUser();
  return user?.fullname || 'Guest';
});

Effect Cleanup

Effects automatically clean up:
export class MyComponent {
  constructor() {
    effect(() => {
      // Automatically cleaned up when component destroys
      console.log('User:', this.authService.currentUser());
    });
  }
}

Testing

Testing Services with Signals

import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';

describe('AuthService', () => {
  let service: AuthService;
  
  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(AuthService);
  });
  
  it('should update authentication state', () => {
    expect(service.isAuthenticated()).toBe(false);
    
    service.isAuthenticated.set(true);
    
    expect(service.isAuthenticated()).toBe(true);
  });
  
  it('should update currentUser', () => {
    const mockUser: UserInfo = { id: '1', username: 'test', ... };
    
    service.currentUser.set(mockUser);
    
    expect(service.currentUser()).toEqual(mockUser);
  });
});

Testing Components with Signals

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';

describe('HeaderComponent', () => {
  let component: HeaderComponent;
  let fixture: ComponentFixture<HeaderComponent>;
  let authService: jasmine.SpyObj<AuthService>;
  
  beforeEach(() => {
    const authServiceSpy = jasmine.createSpyObj('AuthService', [], {
      isAuthenticated: signal(false),
      currentUser: signal(null)
    });
    
    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: authServiceSpy }
      ]
    });
    
    authService = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
    fixture = TestBed.createComponent(HeaderComponent);
    component = fixture.componentInstance;
  });
  
  it('should display user name when logged in', () => {
    const mockUser: UserInfo = { fullname: 'John Doe', ... };
    authService.currentUser.set(mockUser);
    authService.isAuthenticated.set(true);
    
    fixture.detectChanges();
    
    expect(component.userName()).toBe('John Doe');
  });
});

Build docs developers (and LLMs) love