State Management with NGXS
Syneo/Barcoding uses NGXS for state management across all three Angular application instances.
Why NGXS?
NGXS provides several advantages over other state management solutions:
- Less Boilerplate - Simpler than NgRx with less code
- Type Safety - Full TypeScript support with strong typing
- Easy to Learn - Intuitive API similar to Vuex
- DevTools - Excellent debugging with Redux DevTools
- Performance - Optimized for Angular with OnPush strategy
Core Concepts
State
State is a class decorated with @State() that defines the shape and default values:
export interface ModuleStateModel {
modules: Module[];
activeModule: Module | null;
loading: boolean;
}
@State<ModuleStateModel>({
name: 'modules',
defaults: {
modules: [],
activeModule: null,
loading: false
}
})
@Injectable()
export class ModuleStore {
// ...
}Actions
Actions are classes with a readonly type property that describe state changes:
export class LoadModules {
static readonly type = '[Modules] Load Modules';
}
export class SetActiveModule {
static readonly type = '[Modules] Set Active Module';
constructor(public module: Module) {}
}
export class ClearActiveModule {
static readonly type = '[Modules] Clear Active Module';
}Action Handlers
Methods decorated with @Action() handle state transitions:
@State<ModuleStateModel>({
name: 'modules',
defaults: { modules: [], activeModule: null, loading: false }
})
@Injectable()
export class ModuleStore {
constructor(private moduleService: ModuleControllerService) {}
@Action(LoadModules)
loadModules(ctx: StateContext<ModuleStateModel>) {
ctx.patchState({ loading: true });
return this.moduleService.moduleControllerFind().pipe(
tap(modules => {
ctx.patchState({
modules,
loading: false
});
}),
catchError(error => {
ctx.patchState({ loading: false });
return throwError(error);
})
);
}
@Action(SetActiveModule)
setActiveModule(ctx: StateContext<ModuleStateModel>, action: SetActiveModule) {
ctx.patchState({ activeModule: action.module });
}
@Action(ClearActiveModule)
clearActiveModule(ctx: StateContext<ModuleStateModel>) {
ctx.patchState({ activeModule: null });
}
}Selectors
Selectors access state slices with @Selector():
@State<ModuleStateModel>({
name: 'modules',
defaults: { modules: [], activeModule: null, loading: false }
})
@Injectable()
export class ModuleStore {
@Selector()
static modules(state: ModuleStateModel): Module[] {
return state.modules;
}
@Selector()
static activeModule(state: ModuleStateModel): Module | null {
return state.activeModule;
}
@Selector()
static loading(state: ModuleStateModel): boolean {
return state.loading;
}
@Selector()
static activeModuleId(state: ModuleStateModel): number | null {
return state.activeModule?.id ?? null;
}
}Using State in Components
Selecting State
Use @Select() decorator to access state:
import { Select, Store } from '@ngxs/store';
import { Observable } from 'rxjs';
@Component({
selector: 'app-module-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngFor="let module of modules$ | async">
{{ module.name }}
</div>
`
})
export class ModuleListComponent {
@Select(ModuleStore.modules) modules$: Observable<Module[]>;
@Select(ModuleStore.loading) loading$: Observable<boolean>;
}Or use the store directly:
export class ModuleListComponent {
modules$: Observable<Module[]>;
constructor(private store: Store) {
this.modules$ = this.store.select(ModuleStore.modules);
}
}Dispatching Actions
Use the store's dispatch() method:
export class ModuleListComponent implements OnInit {
constructor(private store: Store) {}
ngOnInit() {
// Dispatch single action
this.store.dispatch(new LoadModules());
}
selectModule(module: Module) {
// Dispatch with payload
this.store.dispatch(new SetActiveModule(module));
}
clearModule() {
// Dispatch simple action
this.store.dispatch(new ClearActiveModule());
}
loadAndSelect(moduleId: number) {
// Dispatch multiple actions
this.store.dispatch([
new LoadModules(),
new SetActiveModule(moduleId)
]);
}
}Core Stores
ModuleStore (@barcoding/core)
Manages module and navigation state:
interface ModuleStateModel {
modules: Module[];
activeModule: Module | null;
loading: boolean;
}Actions:
LoadModules- Load all modulesSetActiveModule- Set active moduleClearActiveModule- Clear active module
Selectors:
ModuleStore.modules- All modulesModuleStore.activeModule- Active moduleModuleStore.loading- Loading state
WidgetStore (@barcoding/core)
Manages widget configurations:
interface WidgetStateModel {
widgets: Widget[];
layouts: { [key: string]: GridsterItem[] };
loading: boolean;
}Actions:
LoadWidgets- Load all widgetsSaveWidgetLayout- Save widget layoutAddWidget- Add new widgetRemoveWidget- Remove widgetUpdateWidget- Update widget config
Selectors:
WidgetStore.widgets- All widgetsWidgetStore.layouts- Widget layoutsWidgetStore.loading- Loading state
AuthState (@barcoding/auth-core)
Manages authentication state:
interface AuthStateModel {
user: User | null;
token: string | null;
isAuthenticated: boolean;
permissions: string[];
}Actions:
Login- Login userLogout- Logout userRefreshToken- Refresh JWT tokenLoadPermissions- Load user permissions
Selectors:
AuthState.user- Current userAuthState.isAuthenticated- Auth statusAuthState.token- JWT tokenAuthState.permissions- User permissions
Creating a New Store
1. Define State Model
// ticket.state.model.ts
export interface TicketStateModel {
tickets: Ticket[];
selectedTicket: Ticket | null;
filters: TicketFilters;
loading: boolean;
error: string | null;
}2. Define Actions
// ticket.actions.ts
export class LoadTickets {
static readonly type = '[Ticket] Load Tickets';
constructor(public filters?: TicketFilters) {}
}
export class LoadTicketsSuccess {
static readonly type = '[Ticket] Load Tickets Success';
constructor(public tickets: Ticket[]) {}
}
export class LoadTicketsFailure {
static readonly type = '[Ticket] Load Tickets Failure';
constructor(public error: string) {}
}
export class SelectTicket {
static readonly type = '[Ticket] Select Ticket';
constructor(public ticketId: number) {}
}
export class UpdateFilters {
static readonly type = '[Ticket] Update Filters';
constructor(public filters: Partial<TicketFilters>) {}
}3. Create State Class
// ticket.store.ts
import { State, Action, StateContext, Selector } from '@ngxs/store';
import { Injectable } from '@angular/core';
import { tap, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
@State<TicketStateModel>({
name: 'ticket',
defaults: {
tickets: [],
selectedTicket: null,
filters: {},
loading: false,
error: null
}
})
@Injectable()
export class TicketStore {
constructor(private ticketService: TicketControllerService) {}
@Selector()
static tickets(state: TicketStateModel): Ticket[] {
return state.tickets;
}
@Selector()
static selectedTicket(state: TicketStateModel): Ticket | null {
return state.selectedTicket;
}
@Selector()
static loading(state: TicketStateModel): boolean {
return state.loading;
}
@Selector()
static error(state: TicketStateModel): string | null {
return state.error;
}
@Action(LoadTickets)
loadTickets(ctx: StateContext<TicketStateModel>, action: LoadTickets) {
ctx.patchState({ loading: true, error: null });
return this.ticketService.ticketControllerFind(action.filters).pipe(
tap(tickets => {
ctx.dispatch(new LoadTicketsSuccess(tickets));
}),
catchError(error => {
ctx.dispatch(new LoadTicketsFailure(error.message));
return throwError(error);
})
);
}
@Action(LoadTicketsSuccess)
loadTicketsSuccess(ctx: StateContext<TicketStateModel>, action: LoadTicketsSuccess) {
ctx.patchState({
tickets: action.tickets,
loading: false
});
}
@Action(LoadTicketsFailure)
loadTicketsFailure(ctx: StateContext<TicketStateModel>, action: LoadTicketsFailure) {
ctx.patchState({
loading: false,
error: action.error
});
}
@Action(SelectTicket)
selectTicket(ctx: StateContext<TicketStateModel>, action: SelectTicket) {
const state = ctx.getState();
const ticket = state.tickets.find(t => t.id === action.ticketId);
ctx.patchState({ selectedTicket: ticket ?? null });
}
@Action(UpdateFilters)
updateFilters(ctx: StateContext<TicketStateModel>, action: UpdateFilters) {
const state = ctx.getState();
ctx.patchState({
filters: { ...state.filters, ...action.filters }
});
// Reload tickets with new filters
ctx.dispatch(new LoadTickets(ctx.getState().filters));
}
}4. Register State
// app.config.ts or module
import { NgxsModule } from '@ngxs/store';
import { TicketStore } from './store/ticket.store';
export const appConfig: ApplicationConfig = {
providers: [
importProvidersFrom(
NgxsModule.forRoot([TicketStore], {
developmentMode: !environment.production
})
)
]
};Advanced Patterns
Computed Selectors
Create derived state with @Selector():
@State<TicketStateModel>({ /* ... */ })
export class TicketStore {
@Selector()
static openTickets(state: TicketStateModel): Ticket[] {
return state.tickets.filter(t => t.status === 'open');
}
@Selector()
static closedTickets(state: TicketStateModel): Ticket[] {
return state.tickets.filter(t => t.status === 'closed');
}
@Selector()
static ticketCount(state: TicketStateModel): number {
return state.tickets.length;
}
}Selector Composition
Combine selectors for complex queries:
@State<TicketStateModel>({ /* ... */ })
export class TicketStore {
@Selector([TicketStore.tickets, AuthState.user])
static myTickets(tickets: Ticket[], user: User): Ticket[] {
return tickets.filter(t => t.assignedTo === user.id);
}
}Async Actions
Return Observables from action handlers:
@Action(CreateTicket)
createTicket(ctx: StateContext<TicketStateModel>, action: CreateTicket) {
return this.ticketService.ticketControllerCreate(action.ticket).pipe(
tap(newTicket => {
const state = ctx.getState();
ctx.patchState({
tickets: [...state.tickets, newTicket]
});
})
);
}Action Lifecycle
Use lifecycle events:
@Action(LoadTickets, { cancelUncompleted: true })
loadTickets(ctx: StateContext<TicketStateModel>) {
// cancelUncompleted: true cancels previous incomplete actions
}Best Practices
1. Keep State Normalized
Store entities by ID for efficient updates:
interface TicketStateModel {
tickets: { [id: number]: Ticket }; // Normalized
ticketIds: number[]; // Order
selectedId: number | null;
}2. Use Immutable Updates
Always return new objects:
// ❌ Bad: Mutates state
@Action(UpdateTicket)
updateTicket(ctx: StateContext<TicketStateModel>, action: UpdateTicket) {
const state = ctx.getState();
const ticket = state.tickets.find(t => t.id === action.id);
ticket.status = action.status; // Mutation!
}
// ✅ Good: Immutable update
@Action(UpdateTicket)
updateTicket(ctx: StateContext<TicketStateModel>, action: UpdateTicket) {
const state = ctx.getState();
const tickets = state.tickets.map(t =>
t.id === action.id ? { ...t, status: action.status } : t
);
ctx.patchState({ tickets });
}3. Handle Errors Gracefully
Always handle errors in actions:
@Action(LoadTickets)
loadTickets(ctx: StateContext<TicketStateModel>) {
return this.ticketService.ticketControllerFind().pipe(
catchError(error => {
ctx.patchState({ error: error.message });
return throwError(error);
})
);
}4. Use Action Groups
Organize related actions:
export namespace TicketActions {
export class Load {
static readonly type = '[Ticket] Load';
}
export class Create {
static readonly type = '[Ticket] Create';
constructor(public ticket: Ticket) {}
}
export class Update {
static readonly type = '[Ticket] Update';
constructor(public id: number, public changes: Partial<Ticket>) {}
}
}Debugging with DevTools
Install Redux DevTools
- Install browser extension: Redux DevTools
- NGXS automatically connects in development mode
Enable in Production
NgxsModule.forRoot([/* stores */], {
developmentMode: !environment.production
})Time-Travel Debugging
- View all actions in chronological order
- Jump to any previous state
- Replay actions
- Export/import state
Next Steps
- Creating Modules - Create feature modules with state
- Testing - Test your stores
- API Integration - Learn API patterns