STATE MANAGEMENT

Ecosistema completo para organización y gestión de contenido con interfaces espaciales

State Management Patterns - User Management Module

Comprehensive guide for working with NGXS state in the user-management module.

Table of Contents


Architecture Overview

The user-management module uses NGXS for state management with the following architecture:

┌─────────────────────────────────────────────────────────┐
│                    Components/Services                   │
│                  (Dispatch Actions)                      │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│                    NGXS Store                            │
│                  (UserState)                             │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│                  Action Handlers                         │
│          (Call Services, Emit Events)                    │
└─────────────────────┬───────────────────────────────────┘
                      │
         ┌────────────┴────────────┐
         ▼                         ▼
┌──────────────────┐      ┌───────────────────┐
│  UserApiService  │      │  EventBusService  │
│   (HTTP Calls)   │      │  (Domain Events)  │
└──────────────────┘      └───────────────────┘

Key Principles:


State Structure

UserStateModel Interface

export interface UserStateModel {
  /** List of users */
  users: User[];

  /** Currently selected user (for detail view) */
  selectedUser: User | null;

  /** Loading state for async operations */
  loading: boolean;

  /** Error message from failed operations */
  error: string | null;

  /** Current filter criteria */
  filters: UserFilters;

  /** IDs of users selected for bulk operations */
  bulkSelection: string[];

  /** Total count of users (for pagination) */
  total: number;
}

Default State

{
  users: [],
  selectedUser: null,
  loading: false,
  error: null,
  filters: {},
  bulkSelection: [],
  total: 0
}

Actions Reference

Load Operations

LoadUsers

Fetches users from API with optional filters.

import { LoadUsers } from '@libs-core/user-management';

// Load all users
this.store.dispatch(new LoadUsers());

// Load with filters
this.store.dispatch(new LoadUsers({
  search: 'john',
  status: ['active'],
  roleIds: ['admin-role-id']
}));

State Changes:

LoadUsersSuccess / LoadUsersFailure

Internal actions - dispatched automatically by LoadUsers handler.


CRUD Operations

CreateUser

Creates a new user.

import { CreateUser } from '@libs-core/user-management';

this.store.dispatch(new CreateUser({
  email: '[email protected]',
  username: 'johndoe',
  firstName: 'John',
  lastName: 'Doe',
  primaryRole: SystemRoles.USER
}));

State Changes:

Events Published:

{
  eventName: 'user.created',
  payload: {
    user: User,
    createdBy: string,
    tenantId: string,
    correlationId?: string
  }
}

UpdateUser

Updates existing user.

import { UpdateUser } from '@libs-core/user-management';

this.store.dispatch(new UpdateUser({
  userId: 'user-123',
  firstName: 'Jane',
  lastName: 'Smith'
}));

State Changes:

DeleteUser

Deletes a user.

import { DeleteUser } from '@libs-core/user-management';

this.store.dispatch(new DeleteUser('user-123'));

State Changes:


Selection Operations

SelectUser

Sets the currently selected user.

import { SelectUser } from '@libs-core/user-management';

this.store.dispatch(new SelectUser(user));

// Clear selection
this.store.dispatch(new SelectUser(null));

State Changes:

Use Case: Detail views, edit forms

ClearSelection

Clears the selected user.

import { ClearSelection } from '@libs-core/user-management';

this.store.dispatch(new ClearSelection());

Bulk Operations

SelectMultipleUsers

Selects users for bulk operations.

import { SelectMultipleUsers } from '@libs-core/user-management';

this.store.dispatch(new SelectMultipleUsers([
  'user-1',
  'user-2',
  'user-3'
]));

State Changes:

ClearBulkSelection

Clears bulk selection.

import { ClearBulkSelection } from '@libs-core/user-management';

this.store.dispatch(new ClearBulkSelection());

BulkUpdateUsers

Performs bulk operation on selected users.

import { BulkUpdateUsers } from '@libs-core/user-management';

this.store.dispatch(new BulkUpdateUsers({
  operation: 'activate',  // 'activate' | 'deactivate' | 'delete' | 'changeRole'
  userIds: ['user-1', 'user-2'],
  options: {
    // Operation-specific options
    newRole: 'manager-role-id'  // For changeRole operation
  }
}));

State Changes:

Events Published:

{
  eventName: 'user.bulk.operation',
  payload: {
    operation: 'activate' | 'deactivate' | 'delete' | 'changeRole',
    userIds: string[],
    successCount: number,
    failureCount: number,
    executedBy: string,
    tenantId: string
  }
}

Filter Operations

SetFilters

Updates filter criteria.

import { SetFilters } from '@libs-core/user-management';

this.store.dispatch(new SetFilters({
  search: 'john',
  status: ['active', 'pending'],
  roleIds: ['admin-role', 'manager-role'],
  createdAfter: '2024-01-01',
  createdBefore: '2024-12-31'
}));

State Changes:

ClearFilters

Resets all filters.

import { ClearFilters } from '@libs-core/user-management';

this.store.dispatch(new ClearFilters());

State Changes:


Status Operations

ChangeUserStatus

Changes user status with optional reason.

import { ChangeUserStatus } from '@libs-core/user-management';

this.store.dispatch(new ChangeUserStatus({
  userId: 'user-123',
  newStatus: 'suspended',
  reason: 'Policy violation'
}));

State Changes:

Events Published:

{
  eventName: 'user.status.changed',
  payload: {
    user: User,
    previousStatus: string,
    newStatus: string,
    changedBy: string,
    tenantId: string,
    reason?: string
  }
}

Selectors Usage

Basic Selectors

import { UserState } from '@libs-core/user-management';
import { Store } from '@ngxs/store';

export class MyComponent {
  private store = inject(Store);

  // Get all users
  users$ = this.store.select(UserState.getUsers);

  // Get selected user
  selectedUser$ = this.store.select(UserState.getSelectedUser);

  // Get loading state
  loading$ = this.store.select(UserState.isLoading);

  // Get error
  error$ = this.store.select(UserState.getError);

  // Get current filters
  filters$ = this.store.select(UserState.getFilters);

  // Get bulk selection
  bulkSelection$ = this.store.select(UserState.getBulkSelection);

  // Get total count
  total$ = this.store.select(UserState.getTotal);
}

Using with Signals

import { toSignal } from '@angular/core/rxjs-interop';

export class MyComponent {
  users = toSignal(this.store.select(UserState.getUsers), {
    initialValue: []
  });

  selectedUser = toSignal(this.store.select(UserState.getSelectedUser));

  loading = toSignal(this.store.select(UserState.isLoading), {
    initialValue: false
  });
}

Computed Values

export class MyComponent {
  users = toSignal(this.store.select(UserState.getUsers), { initialValue: [] });

  // Computed: active users only
  activeUsers = computed(() => {
    return this.users().filter(u => u.status === 'active');
  });

  // Computed: admin users
  adminUsers = computed(() => {
    return this.users().filter(u =>
      u.roles.some(r => r.name === SystemRoles.ADMIN)
    );
  });

  // Computed: user count
  userCount = computed(() => this.users().length);

  // Computed: has selected user
  hasSelection = computed(() => this.selectedUser() !== null);
}

Common Patterns

Pattern 1: Load-Display-Error

Complete pattern for loading data with error handling:

export class UserListPageComponent {
  private store = inject(Store);

  users = toSignal(this.store.select(UserState.getUsers), { initialValue: [] });
  loading = toSignal(this.store.select(UserState.isLoading), { initialValue: false });
  error = toSignal(this.store.select(UserState.getError));

  ngOnInit() {
    // Load users on component init
    this.store.dispatch(new LoadUsers());

    // Handle errors
    effect(() => {
      const errorMessage = this.error();
      if (errorMessage) {
        this.showErrorNotification(errorMessage);
      }
    });
  }

  private showErrorNotification(message: string) {
    // Show error toast/notification
  }
}

Pattern 2: Filter-Search-Pagination

Complete pattern for filtered, searchable, paginated lists:

export class UserListComponent {
  private store = inject(Store);

  users = toSignal(this.store.select(UserState.getUsers), { initialValue: [] });
  total = toSignal(this.store.select(UserState.getTotal), { initialValue: 0 });
  filters = toSignal(this.store.select(UserState.getFilters), { initialValue: {} });

  // Pagination state
  currentPage = signal(1);
  pageSize = signal(20);

  // Computed: paginated users
  paginatedUsers = computed(() => {
    const start = (this.currentPage() - 1) * this.pageSize();
    const end = start + this.pageSize();
    return this.users().slice(start, end);
  });

  // Apply filters
  applyFilters(filters: UserFilters) {
    this.store.dispatch(new SetFilters(filters));
    this.currentPage.set(1); // Reset to first page
  }

  // Search
  search(query: string) {
    this.store.dispatch(new SetFilters({ ...this.filters(), search: query }));
    this.currentPage.set(1);
  }

  // Pagination
  goToPage(page: number) {
    this.currentPage.set(page);
  }
}

Pattern 3: Create-Edit Form

Complete pattern for form submission with state management:

export class UserFormComponent {
  private store = inject(Store);
  private router = inject(Router);

  mode = signal<'create' | 'edit'>('create');
  userId = input<string | null>(null);

  selectedUser = toSignal(this.store.select(UserState.getSelectedUser));
  loading = toSignal(this.store.select(UserState.isLoading), { initialValue: false });

  ngOnInit() {
    const id = this.userId();
    if (id) {
      this.mode.set('edit');
      // Load user for editing
      this.loadUser(id);
    }
  }

  private loadUser(id: string) {
    // Load and select user
    this.store.dispatch(new LoadUsers()).subscribe(() => {
      const users = this.store.selectSnapshot(UserState.getUsers);
      const user = users.find(u => u.id === id);
      if (user) {
        this.store.dispatch(new SelectUser(user));
      }
    });
  }

  onSubmit(formData: CreateUserRequest | UpdateUserRequest) {
    if (this.mode() === 'create') {
      this.store.dispatch(new CreateUser(formData as CreateUserRequest))
        .subscribe({
          next: () => this.router.navigate(['/users']),
          error: (error) => this.handleError(error)
        });
    } else {
      this.store.dispatch(new UpdateUser({
        userId: this.userId()!,
        ...formData
      }))
        .subscribe({
          next: () => this.router.navigate(['/users']),
          error: (error) => this.handleError(error)
        });
    }
  }

  onCancel() {
    this.router.navigate(['/users']);
  }

  private handleError(error: unknown) {
    console.error('Form submission failed:', error);
  }
}

Pattern 4: Bulk Operations

Complete pattern for bulk user operations:

export class UserBulkActionsComponent {
  private store = inject(Store);

  bulkSelection = toSignal(this.store.select(UserState.getBulkSelection), {
    initialValue: []
  });

  hasSelection = computed(() => this.bulkSelection().length > 0);
  selectionCount = computed(() => this.bulkSelection().length);

  // Select users
  selectUsers(userIds: string[]) {
    this.store.dispatch(new SelectMultipleUsers(userIds));
  }

  // Clear selection
  clearSelection() {
    this.store.dispatch(new ClearBulkSelection());
  }

  // Bulk activate
  activateSelected() {
    if (!this.hasSelection()) return;

    this.store.dispatch(new BulkUpdateUsers({
      operation: 'activate',
      userIds: this.bulkSelection(),
      options: {}
    }));
  }

  // Bulk deactivate
  deactivateSelected() {
    if (!this.hasSelection()) return;

    this.store.dispatch(new BulkUpdateUsers({
      operation: 'deactivate',
      userIds: this.bulkSelection(),
      options: {}
    }));
  }

  // Bulk delete (with confirmation)
  deleteSelected() {
    if (!this.hasSelection()) return;

    const confirmed = confirm(
      `Delete ${this.selectionCount()} users? This cannot be undone.`
    );

    if (confirmed) {
      this.store.dispatch(new BulkUpdateUsers({
        operation: 'delete',
        userIds: this.bulkSelection(),
        options: {}
      }));
    }
  }

  // Bulk change role
  changeRoleForSelected(newRoleId: string) {
    if (!this.hasSelection()) return;

    this.store.dispatch(new BulkUpdateUsers({
      operation: 'changeRole',
      userIds: this.bulkSelection(),
      options: { newRole: newRoleId }
    }));
  }
}

Event Integration

Subscribing to State-Triggered Events

The UserState automatically publishes domain events. Subscribe in other modules:

import { EventBusService } from '@libs-core/event-system';
import {
  UserCreatedEvent,
  UserUpdatedEvent,
  UserDeletedEvent,
  UserStatusChangedEvent,
  UserBulkOperationEvent
} from '@libs-core/user-management';

export class AuditLogService {
  private eventBus = inject(EventBusService);

  constructor() {
    this.subscribeToUserEvents();
  }

  private subscribeToUserEvents() {
    // Log user creation
    this.eventBus.subscribe(UserCreatedEvent, (event) => {
      this.logAudit({
        action: 'USER_CREATED',
        userId: event.payload.user.id,
        performedBy: event.payload.createdBy,
        tenantId: event.payload.tenantId,
        timestamp: event.metadata.timestamp,
        data: { email: event.payload.user.email }
      });
    });

    // Log user updates
    this.eventBus.subscribe(UserUpdatedEvent, (event) => {
      this.logAudit({
        action: 'USER_UPDATED',
        userId: event.payload.user.id,
        performedBy: event.payload.updatedBy,
        tenantId: event.payload.tenantId,
        timestamp: event.metadata.timestamp,
        data: {
          previousData: event.payload.previousData,
          newData: event.payload.user
        }
      });
    });

    // Log user deletion (high priority)
    this.eventBus.subscribe(UserDeletedEvent, (event) => {
      this.logAudit({
        action: 'USER_DELETED',
        userId: event.payload.userId,
        performedBy: event.payload.deletedBy,
        tenantId: event.payload.tenantId,
        timestamp: event.metadata.timestamp,
        priority: 'HIGH',
        data: { email: event.payload.userEmail }
      });
    });
  }

  private logAudit(entry: AuditLogEntry) {
    // Send to audit log system
  }
}

Best Practices

1. Use Action Creators

Always use the action classes, never construct manually:

// ✅ Correct
this.store.dispatch(new LoadUsers(filters));

// ❌ Wrong
this.store.dispatch({ type: '[User] Load Users', filters });

2. Handle Async Operations

Always handle both success and error cases:

this.store.dispatch(new CreateUser(request))
  .subscribe({
    next: () => {
      this.showSuccess('User created successfully');
      this.router.navigate(['/users']);
    },
    error: (error) => {
      this.showError('Failed to create user');
      console.error(error);
    }
  });

3. Avoid Nested Subscriptions

Use operators like switchMap instead:

// ❌ Bad: nested subscriptions
this.store.dispatch(new LoadUsers()).subscribe(() => {
  this.store.dispatch(new SelectUser(user)).subscribe(() => {
    // More actions...
  });
});

// ✅ Good: use RxJS operators
import { Actions, ofActionSuccessful } from '@ngxs/store';

export class MyComponent {
  private actions$ = inject(Actions);

  ngOnInit() {
    this.actions$
      .pipe(
        ofActionSuccessful(LoadUsersSuccess),
        switchMap(() => this.store.dispatch(new SelectUser(user)))
      )
      .subscribe();
  }
}

4. Use Selectors, Not Direct State Access

// ✅ Correct
this.users$ = this.store.select(UserState.getUsers);

// ❌ Wrong
this.users$ = this.store.select(state => state.users.users);

5. Clear Error State After Handling

effect(() => {
  const error = this.error();
  if (error) {
    this.showErrorNotification(error);
    // Clear error after showing notification
    this.store.dispatch(new ClearError());
  }
});

6. Use Snapshots for Synchronous Access

When you need the current value immediately:

const currentUsers = this.store.selectSnapshot(UserState.getUsers);
const isLoading = this.store.selectSnapshot(UserState.isLoading);

if (!isLoading && currentUsers.length === 0) {
  this.store.dispatch(new LoadUsers());
}

7. Optimize Re-renders with OnPush

All components use OnPush change detection - ensure immutability:

// ✅ Correct: creates new array
const updatedUsers = [...users, newUser];

// ❌ Wrong: mutates existing array
users.push(newUser);

Performance Optimization

1. Memoized Selectors

For expensive computations, create memoized selectors:

@Selector([UserState.getUsers])
static getActiveUsers(users: User[]): User[] {
  return users.filter(u => u.status === 'active');
}

2. Lazy Loading

Load users only when needed:

export class UserListComponent implements OnInit {
  private hasLoaded = false;

  ngOnInit() {
    const users = this.store.selectSnapshot(UserState.getUsers);
    if (users.length === 0 && !this.hasLoaded) {
      this.store.dispatch(new LoadUsers());
      this.hasLoaded = true;
    }
  }
}

3. Debounce Search Input

Debounce search to reduce API calls:

searchControl = new FormControl('');

ngOnInit() {
  this.searchControl.valueChanges
    .pipe(
      debounceTime(300),
      distinctUntilChanged(),
      tap(query => this.store.dispatch(new SetFilters({ search: query })))
    )
    .subscribe();
}

4. Virtual Scrolling

For large lists, use virtual scrolling:

import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';

@Component({
  template: `
    <cdk-virtual-scroll-viewport itemSize="50" class="user-list">
      @for (user of users(); track user.id) {
        <lib-user-card [user]="user" />
      }
    </cdk-virtual-scroll-viewport>
  `
})

5. Pagination Over Full Load

Load paginated data instead of all users:

loadPage(page: number, pageSize: number) {
  this.store.dispatch(new LoadUsers({
    ...this.currentFilters,
    pagination: { page, pageSize }
  }));
}

Testing State

Unit Testing Actions

import { TestBed } from '@angular/core/testing';
import { Store, provideStore } from '@ngxs/store';
import { UserState } from './user.state';

describe('UserState', () => {
  let store: Store;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideStore([UserState])]
    });

    store = TestBed.inject(Store);
  });

  it('should select user', async () => {
    const user = { id: 'user-1', name: 'John' };
    await store.dispatch(new SelectUser(user)).toPromise();

    const selected = store.selectSnapshot(UserState.getSelectedUser);
    expect(selected).toEqual(user);
  });
});

Common Issues & Solutions

Issue: State not updating after action dispatch

Solution: Ensure you're returning new objects, not mutating:

// ✅ Correct
patchState({ users: [...users, newUser] });

// ❌ Wrong
users.push(newUser);
patchState({ users });

Issue: Component not re-rendering

Solution: Check OnPush change detection and signal/observable usage:

// ✅ Correct
users = toSignal(this.store.select(UserState.getUsers));

// In template
@for (user of users(); track user.id) { ... }

Issue: Memory leaks from subscriptions

Solution: Use takeUntil or async pipe:

private destroy$ = new Subject<void>();

ngOnInit() {
  this.store.select(UserState.getUsers)
    .pipe(takeUntil(this.destroy$))
    .subscribe(users => { ... });
}

ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

Resources