Skip to content

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:

typescript
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:

typescript
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:

typescript
@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():

typescript
@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:

typescript
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:

typescript
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:

typescript
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:

typescript
interface ModuleStateModel {
  modules: Module[];
  activeModule: Module | null;
  loading: boolean;
}

Actions:

  • LoadModules - Load all modules
  • SetActiveModule - Set active module
  • ClearActiveModule - Clear active module

Selectors:

  • ModuleStore.modules - All modules
  • ModuleStore.activeModule - Active module
  • ModuleStore.loading - Loading state

WidgetStore (@barcoding/core)

Manages widget configurations:

typescript
interface WidgetStateModel {
  widgets: Widget[];
  layouts: { [key: string]: GridsterItem[] };
  loading: boolean;
}

Actions:

  • LoadWidgets - Load all widgets
  • SaveWidgetLayout - Save widget layout
  • AddWidget - Add new widget
  • RemoveWidget - Remove widget
  • UpdateWidget - Update widget config

Selectors:

  • WidgetStore.widgets - All widgets
  • WidgetStore.layouts - Widget layouts
  • WidgetStore.loading - Loading state

AuthState (@barcoding/auth-core)

Manages authentication state:

typescript
interface AuthStateModel {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  permissions: string[];
}

Actions:

  • Login - Login user
  • Logout - Logout user
  • RefreshToken - Refresh JWT token
  • LoadPermissions - Load user permissions

Selectors:

  • AuthState.user - Current user
  • AuthState.isAuthenticated - Auth status
  • AuthState.token - JWT token
  • AuthState.permissions - User permissions

Creating a New Store

1. Define State Model

typescript
// ticket.state.model.ts
export interface TicketStateModel {
  tickets: Ticket[];
  selectedTicket: Ticket | null;
  filters: TicketFilters;
  loading: boolean;
  error: string | null;
}

2. Define Actions

typescript
// 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

typescript
// 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

typescript
// 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():

typescript
@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:

typescript
@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:

typescript
@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:

typescript
@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:

typescript
interface TicketStateModel {
  tickets: { [id: number]: Ticket };  // Normalized
  ticketIds: number[];                 // Order
  selectedId: number | null;
}

2. Use Immutable Updates

Always return new objects:

typescript
// ❌ 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:

typescript
@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:

typescript
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

  1. Install browser extension: Redux DevTools
  2. NGXS automatically connects in development mode

Enable in Production

typescript
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

Syneo/Barcoding Documentation