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'
Facades orchestrate business logic:
Coordinate multiple stores/services
Handle async operations (HTTP, timers)
Update stores based on responses
Expose simplified API to components
Effects react to state changes:
Use Angular effect() API
Auto-trigger side effects (refresh tokens, sync data)
Declarative reactive programming
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 } }>()
);
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
Inject Facades in Components
Components should inject facades, not stores directly
Facades provide a simplified API and hide complexity
Stores remain internal implementation details
Use Effects for Auto-Reactions
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