Skip to main content

State Management

Rodando Passenger uses NgRx Signals for reactive state management, implementing a Store + Facade pattern that separates state definition from business logic.

Architecture Pattern

Key Concepts

Stores hold reactive state using Angular signals:
  • Define state interface and initial values
  • Expose computed selectors
  • Provide mutation methods (no business logic)
  • Always injectable with providedIn: 'root'
Why this pattern? Separating stores (state) from facades (logic) improves testability, reusability, and maintainability. Stores are pure and predictable, while facades handle complexity.

Store Pattern

State Definition

Stores define their state interface and initial values:
src/app/store/auth/auth.store.ts:6-18
export interface AuthState {
  accessToken: string | null;
  refreshTokenInMemory: string | null;
  user: UserProfile | null;
  loading: boolean;
  error: any | null;
  sessionType?: SessionType | null;
  usesCookie?: boolean | null;
  accessTokenExpiresAt?: number | null;
  refreshTokenExpiresAt?: number | null;
  sid?: string | null;
  refreshInProgress?: boolean;
}

const initialState: AuthState = {
  accessToken: null,
  refreshTokenInMemory: null,
  user: null,
  loading: false,
  error: null,
  sessionType: null,
  usesCookie: null,
  accessTokenExpiresAt: null,
  refreshTokenExpiresAt: null,
  sid: null,
  refreshInProgress: false,
};

Signal-Based State

Stores use Angular signals for reactive state:
src/app/store/auth/auth.store.ts:34-69
@Injectable({ providedIn: 'root' })
export class AuthStore {
  // Private signal holding the state
  private readonly _state = signal<AuthState>({ ...initialState });

  // Computed selectors (read-only)
  readonly accessToken = computed(() => this._state().accessToken);
  readonly user = computed(() => this._state().user);
  readonly loading = computed(() => this._state().loading);
  readonly error = computed(() => this._state().error);

  // Derived computed values
  readonly accessTokenExpiresIn = computed(() => {
    const at = this._state().accessTokenExpiresAt;
    if (!at) return null;
    return Math.max(0, at - Date.now());
  });

  readonly isAuthenticated = computed(() => {
    const token = this._state().accessToken;
    const user = this._state().user;
    const exp = this._state().accessTokenExpiresAt ?? 0;
    return !!token && !!user && exp > Date.now();
  });
}
Computed signals automatically recalculate when dependencies change. For example, isAuthenticated updates whenever accessToken, user, or accessTokenExpiresAt changes.

Mutation Methods

Stores provide methods to update state (no async logic):
src/app/store/auth/auth.store.ts:75-132
setAccessToken(token: string | null) {
  this._state.update(s => ({ ...s, accessToken: token }));
}

setUser(user: UserProfile | null) {
  this._state.update(s => ({ ...s, user }));
}

setLoading(loading: boolean) {
  this._state.update(s => ({ ...s, loading }));
}

// Atomic update of multiple fields
setAuth(payload: {
  accessToken?: string | null;
  accessTokenExpiresAt?: number | null;
  refreshTokenInMemory?: string | null;
  user?: UserProfile | null;
  sessionType?: SessionType | null;
  usesCookie?: boolean | null;
  sid?: string | null;
}) {
  this._state.update(s => ({
    ...s,
    accessToken: payload.accessToken ?? s.accessToken,
    accessTokenExpiresAt: payload.accessTokenExpiresAt ?? s.accessTokenExpiresAt,
    refreshTokenInMemory: payload.refreshTokenInMemory ?? s.refreshTokenInMemory,
    user: payload.user ?? s.user,
    sessionType: payload.sessionType ?? s.sessionType,
    usesCookie: payload.usesCookie ?? s.usesCookie,
    sid: payload.sid ?? s.sid,
  }));
}

clear() {
  this._state.set({ ...initialState });
}

Facade Pattern

Business Logic Orchestration

Facades inject stores and services to orchestrate operations:
src/app/store/auth/auth.facade.ts:21-31
@Injectable({ providedIn: 'root' })
export class AuthFacade {
  private readonly authStore = inject(AuthStore);
  private readonly loginStore = inject(LoginStore);
  private readonly secureStorage = inject(SecureStorageService);
  private readonly authService = inject(AuthService);
  private readonly router = inject(Router);
  private readonly platform = inject(Platform);
  private readonly passengerLocationReporter = inject(PassengerLocationReporter);
  private readonly usersStore = inject(UsersStore);
}

Async Operations

Facades handle HTTP requests and update stores:
src/app/store/auth/auth.facade.ts:64-122 (simplified)
login(payload: LoginPayload): Observable<User> {
  this.loginStore.start();

  return this.authService.login(payload, { withCredentials: true }).pipe(
    take(1),
    switchMap((res: LoginResponse) => {
      const sessionType: SessionType | null = (res as any).sessionType ?? null;
      const usesCookie = this.inferUsesCookieFromSessionType(sessionType);
      
      this.authStore.setAuth({ sessionType, usesCookie });

      if (usesCookie === false) {
        // MOBILE: tokens in body
        const { accessToken, refreshToken } = res as LoginResponseMobile;
        return from(this.setAccessTokenWithExp({
          accessToken,
          refreshToken,
          sessionType,
        })).pipe(
          switchMap(() => this.authService.me(false))
        );
      }

      // WEB: cookie-based
      const accessToken = (res as LoginResponseWeb).accessToken ?? null;
      return from(this.setAccessTokenWithExp({
        accessToken,
        sessionType,
      })).pipe(
        switchMap(() => this.authService.me(true))
      );
    }),
    tap((user) => {
      this.authStore.setUser(user as any);
      this.loginStore.success();
      this.passengerLocationReporter.bootstrapOnLogin();
    }),
    catchError((err: ApiError) => {
      this.loginStore.setError(err);
      this.authStore.clear();
      return throwError(() => err);
    }),
    finalize(() => this.loginStore.clear())
  );
}

Token Refresh with Scheduling

Facades can schedule side effects like auto-refresh:
src/app/store/auth/auth.facade.ts:309-363
public scheduleAutoRefresh(expiresAt?: number | null): void {
  this.clearAutoRefresh();
  if (!expiresAt) return;

  const now = Date.now();
  const ttl = Math.max(0, expiresAt - now);
  const offset = Math.min(this.AUTO_REFRESH_OFFSET ?? 30000, Math.floor(ttl / 2));
  const msUntilRefresh = ttl - offset;

  if (msUntilRefresh <= 0) {
    // Immediate refresh
    Promise.resolve().then(() => {
      this.performRefresh().pipe(take(1)).subscribe();
    });
    return;
  }

  // Schedule with setTimeout
  this.refreshTimerId = setTimeout(() => {
    this.performRefresh().pipe(take(1)).subscribe();
  }, msUntilRefresh) as unknown as number;
}

Real-World Example: Trip Planner

Trip Planner Store

Holds trip planning state:
src/app/store/trips/trip-planner.store.ts:19-63
interface TripPlannerState {
  originPoint: LatLng | null;
  originText: string | null;
  destinationText: string;
  destinationPoint: LatLng | null;
  suggestions: PlaceSuggestion[];
  loading: boolean;
  error: string | null;
  routeSummary: RouteSummary | null;
  vehicleCategories: VehicleCategory[];
  serviceClasses: ServiceClass[];
  selectedVehicleId: string | null;
  selectedServiceClassId: string | null;
  fareQuote: FareQuote | null;
}

@Injectable({ providedIn: 'root' })
export class TripPlannerStore {
  private _state = signal<TripPlannerState>({ ...initialState });

  // Selectors
  readonly originPoint = computed(() => this._state().originPoint);
  readonly destinationPoint = computed(() => this._state().destinationPoint);
  readonly suggestions = computed(() => this._state().suggestions);
  readonly loading = computed(() => this._state().loading);
  readonly fareQuote = computed(() => this._state().fareQuote);
  
  readonly readyToRoute = computed(() =>
    !!(this._state().originPoint && this._state().destinationPoint)
  );

  // Mutations
  setDestinationFromSuggestion(sel: PlaceSuggestion) {
    this._state.update(s => ({
      ...s,
      destinationPoint: sel.coords,
      destinationText: sel.placeName,
      suggestions: [],
      routeSummary: null,
      fareQuote: null, // Invalidate estimate
    }));
  }
}

Trip Planner Facade

Orchestrates autocomplete, routing, and fare estimation:
src/app/store/trips/trip-planner.facade.ts:24-64
@Injectable({ providedIn: 'root' })
export class TripPlannerFacade {
  private store = inject(TripPlannerStore);
  private mapbox = inject(MapboxPlacesService);
  private dir = inject(MapboxDirectionsService);
  private tripsApi = inject(TripsApiService);

  constructor() {
    // Effect: auto-calculate route when origin + destination ready
    effect(() => {
      const ready = this.store.readyToRoute();
      const hasRoute = !!this.store.routeSummary();
      const loading = this.store.loading();

      if (ready && !hasRoute && !loading) {
        queueMicrotask(() => {
          this.computeRouteAndStore().pipe(take(1)).subscribe();
        });
      }
    }, { allowSignalWrites: true });

    // Effect: estimate fare when route + vehicle + service ready
    effect(() => {
      const rsReady = !!this.store.routeSummary();
      const vid = this.store.selectedVehicleId();
      const sid = this.store.selectedServiceClassId();
      if (!rsReady || !vid || !sid) return;

      queueMicrotask(() => {
        this.tripsApi.estimateTrip(this.buildEstimateRequest())
          .pipe(take(1))
          .subscribe({
            next: q => this.store.setFareQuote(q),
            error: () => this.store.setFareQuote(null),
          });
      });
    }, { allowSignalWrites: true });
  }
}
Effects (via Angular’s effect()) automatically trigger when their dependencies change. This enables declarative reactive programming without manual subscriptions.

Store Communication

Cross-Store Dependencies

Facades coordinate multiple stores:
Example: AuthFacade coordinates AuthStore + UsersStore
login(payload: LoginPayload): Observable<User> {
  return this.authService.login(payload).pipe(
    tap((user) => {
      // Update AuthStore
      this.authStore.setUser(user);
      
      // Update UsersStore with normalized data
      this.usersStore.upsertOne(user);
      
      // Trigger location reporter
      this.passengerLocationReporter.bootstrapOnLogin();
    })
  );
}

Event-Based Communication

Some stores use NgRx Store actions for cross-cutting concerns:
src/app/store/notification-alerts/notification-alert.actions.ts
import { createAction, props } from '@ngrx/store';

export const showNotificationAlert = createAction(
  '[Notification Alert] Show',
  props<{ payload: { type: 'success' | 'error'; message: string; duration: number } }>()
);
Usage in Facade
this.store.dispatch(showNotificationAlert({
  payload: { type: 'success', message: 'Login successful', duration: 3000 },
}));

Testing Stores and Facades

Testing Stores

Stores are easy to test because they’re synchronous:
describe('AuthStore', () => {
  let store: AuthStore;

  beforeEach(() => {
    store = new AuthStore();
  });

  it('should set user', () => {
    const user = { id: '123', email: '[email protected]' };
    store.setUser(user);
    expect(store.user()).toEqual(user);
  });

  it('should compute isAuthenticated', () => {
    store.setAuth({
      accessToken: 'token',
      user: { id: '123' },
      accessTokenExpiresAt: Date.now() + 3600000,
    });
    expect(store.isAuthenticated()).toBe(true);
  });
});

Testing Facades

Facades require mocking dependencies:
describe('AuthFacade', () => {
  let facade: AuthFacade;
  let authStoreMock: jasmine.SpyObj<AuthStore>;
  let authServiceMock: jasmine.SpyObj<AuthService>;

  beforeEach(() => {
    authStoreMock = jasmine.createSpyObj('AuthStore', ['setUser', 'setAuth']);
    authServiceMock = jasmine.createSpyObj('AuthService', ['login']);

    TestBed.configureTestingModule({
      providers: [
        AuthFacade,
        { provide: AuthStore, useValue: authStoreMock },
        { provide: AuthService, useValue: authServiceMock },
      ],
    });

    facade = TestBed.inject(AuthFacade);
  });

  it('should login and update store', (done) => {
    authServiceMock.login.and.returnValue(of({ accessToken: 'token' }));

    facade.login({ email: '[email protected]', password: 'password' })
      .subscribe(() => {
        expect(authStoreMock.setAuth).toHaveBeenCalled();
        done();
      });
  });
});

Best Practices

  • Stores should only contain state and mutations
  • No HTTP calls, timers, or side effects in stores
  • All async logic belongs in facades
  • Derive state with computed() instead of storing redundant data
  • Computed values automatically update when dependencies change
  • Example: isAuthenticated computed from token + user + expiresAt
  • Use _state.update() for partial updates
  • Use _state.set() for full resets
  • Group related updates in a single mutation method
  • Components should inject facades, not stores directly
  • Facades provide a simplified API and hide complexity
  • Stores remain internal implementation details
  • Use Angular effect() for declarative side effects
  • Effects run automatically when signal dependencies change
  • Common use cases: auto-refresh, sync, validation

Next Steps

Routing

Learn how guards use AuthStore to protect routes

API Integration

Discover HTTP services used by facades

Build docs developers (and LLMs) love