Professional Guide: Migrating from NGXS to Angular Signals

The Angular ecosystem has evolved significantly with the introduction of Signals in Angular 16. Many teams using NGXS for state management are now considering migration to Signals for better performance, simpler APIs, and improved developer experience.

This comprehensive guide provides a strategic approach to migrating from NGXS to Angular Signals, with practical examples, best practices, and hybrid solutions for complex scenarios.

Why Migrate from NGXS to Signals?

Key Benefits of Signals over NGXS

1. Performance Improvements

  • Fine-grained reactivity: Only affected components re-render
  • No Zone.js overhead: Signals bypass change detection cycles
  • Automatic dependency tracking: Optimized change detection
  • Better memory efficiency: No subscription management required

2. Developer Experience

  • Simpler mental model: Direct reactive primitives vs. store pattern
  • Type safety: Better TypeScript integration
  • Reduced boilerplate: Less ceremony for simple state management
  • Native Angular integration: Built into the framework

3. Bundle Size & Performance

  • Smaller bundle size: No additional library overhead
  • Tree-shaking: Unused code eliminated automatically
  • Faster initial load: Reduced JavaScript payload

Migration Strategy Overview

Migration should be approached incrementally, starting with simple use cases and progressively moving to more complex scenarios.

Migration Phases

  1. Phase 1: Simple component state (no cross-component sharing)
  2. Phase 2: Shared state with computed values
  3. Phase 3: Complex state with side effects
  4. Phase 4: Async operations and API integration
  5. Phase 5: Full application state management

Phase 1: Simple Component State Migration

NGXS Pattern (Before)

// auth.state.ts
@State<AuthStateModel>({
  name: 'auth',
  defaults: {
    token: null,
    user: null,
    loading: false,
    error: null
  }
})
@Injectable()
export class AuthState {
  @Selector()
  static isAuthenticated(state: AuthStateModel): boolean {
    return !!state.token;
  }

  @Action(Login)
  login(ctx: StateContext<AuthStateModel>, action: Login) {
    ctx.patchState({ loading: true });
    return this.authService.login(action.payload).pipe(
      tap(result => {
        ctx.patchState({
          token: result.token,
          user: result.user,
          loading: false,
          error: null
        });
      }),
      catchError(error => {
        ctx.patchState({
          loading: false,
          error: error.message
        });
        return EMPTY;
      })
    );
  }
}
// login.component.ts
@Component({
  selector: 'app-login',
  template: `
    <form *ngIf="!isAuthenticated$ | async; else userInfo">
      <input [(ngModel)]="credentials.email" name="email">
      <input [(ngModel)]="credentials.password" name="password" type="password">
      <button (click)="login()" [disabled]="loading$ | async">
        {{ loading$ | async ? 'Loading...' : 'Login' }}
      </button>
      <div *ngIf="error$ | async as error" class="error">{{ error }}</div>
    </form>
    <ng-template #userInfo>
      <div>Welcome {{ user$ | async?.name }}!</div>
    </ng-template>
  `

})
export class LoginComponent {
  credentials = { email: '', password: '' };
  
  isAuthenticated$ = this.store.select(AuthState.isAuthenticated);
  loading$ = this.store.select(AuthState.loading);
  error$ = this.store.select(AuthState.error);
  user$ = this.store.select(AuthState.user);

  constructor(private store: Store, private authService: AuthService) {}

  login() {
    this.store.dispatch(new Login(this.credentials));
  }
}

Signals Pattern (After)

// auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
  private _token = signal<string | null>(null);
  private _user = signal<User | null>(null);
  private _loading = signal(false);
  private _error = signal<string | null>(null);

  // Computed properties
  readonly isAuthenticated = computed(() => !!this._token());
  readonly token = this._token.asReadonly();
  readonly user = this._user.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();

  constructor(private http: HttpClient) {
    // Effect for token persistence
    effect(() => {
      const token = this._token();
      if (token) {
        localStorage.setItem('auth_token', token);
      } else {
        localStorage.removeItem('auth_token');
      }
    });
  }

  login(credentials: LoginCredentials): Observable<void> {
    this._loading.set(true);
    this._error.set(null);

    return this.http.post<AuthResponse>('/api/login', credentials).pipe(
      tap(response => {
        this._token.set(response.token);
        this._user.set(response.user);
        this._loading.set(false);
      }),
      catchError(error => {
        this._error.set(error.message);
        this._loading.set(false);
        return EMPTY;
      })
    );
  }

  logout() {
    this._token.set(null);
    this._user.set(null);
    this._error.set(null);
  }
}
// login.component.ts
@Component({
  selector: 'app-login',
  template: `
    <form *ngIf="!authService.isAuthenticated(); else userInfo">
      <input [(ngModel)]="credentials.email" name="email">
      <input [(ngModel)]="credentials.password" name="password" type="password">
      <button (click)="login()" [disabled]="authService.loading()">
        {{ authService.loading() ? 'Loading...' : 'Login' }}
      </button>
      <div *ngIf="authService.error() as error" class="error">{{ error }}</div>
    </form>
    <ng-template #userInfo>
      <div>Welcome {{ authService.user()?.name }}!</div>
    </ng-template>
  `

})
export class LoginComponent {
  credentials = { email: '', password: '' };

  constructor(private authService: AuthService) {}

  login() {
    this.authService.login(this.credentials).subscribe();
  }
}

Phase 2: Shared State with Computed Values

NGXS Pattern (Before)

// course.state.ts
@State<CourseStateModel>({
  name: 'course',
  defaults: {
    courses: [],
    selectedCourseId: null,
    loading: false,
    filter: ''
  }
})
@Injectable()
export class CourseState {
  @Selector()
  static courses(state: CourseStateModel): Course[] {
    return state.courses;
  }

  @Selector()
  static selectedCourse(state: CourseStateModel): Course | null {
    return state.courses.find(course => course.id === state.selectedCourseId) || null;
  }

  @Selector()
  static filteredCourses(state: CourseStateModel): Course[] {
    return state.courses.filter(course => 
      course.name.toLowerCase().includes(state.filter.toLowerCase())
    );
  }

  @Action(LoadCourses)
  loadCourses(ctx: StateContext<CourseStateModel>) {
    ctx.patchState({ loading: true });
    return this.courseService.getCourses().pipe(
      tap(courses => {
        ctx.patchState({ courses, loading: false });
      })
    );
  }

  @Action(SelectCourse)
  selectCourse(ctx: StateContext<CourseStateModel>, action: SelectCourse) {
    ctx.patchState({ selectedCourseId: action.courseId });
  }

  @Action(FilterCourses)
  filterCourses(ctx: StateContext<CourseStateModel>, action: FilterCourses) {
    ctx.patchState({ filter: action.filter });
  }
}

Signals Pattern (After)

// course.service.ts
@Injectable({ providedIn: 'root' })
export class CourseService {
  private _courses = signal<Course[]>([]);
  private _selectedCourseId = signal<number | null>(null);
  private _loading = signal(false);
  private _filter = signal('');

  // Computed properties
  readonly courses = this._courses.asReadonly();
  readonly selectedCourse = computed(() => 
    this._courses().find(course => course.id === this._selectedCourseId()) || null
  );
  readonly filteredCourses = computed(() => 
    this._courses().filter(course => 
      course.name.toLowerCase().includes(this._filter().toLowerCase())
    )
  );
  readonly loading = this._loading.asReadonly();
  readonly filter = this._filter.asReadonly();

  constructor(private http: HttpClient) {}

  loadCourses(): Observable<void> {
    this._loading.set(true);
    return this.http.get<Course[]>('/api/courses').pipe(
      tap(courses => {
        this._courses.set(courses);
        this._loading.set(false);
      })
    );
  }

  selectCourse(courseId: number) {
    this._selectedCourseId.set(courseId);
  }

  updateFilter(filter: string) {
    this._filter.set(filter);
  }

  // Additional methods for CRUD operations
  addCourse(course: CreateCourseRequest): Observable<Course> {
    return this.http.post<Course>('/api/courses', course).pipe(
      tap(newCourse => {
        this._courses.update(courses => [...courses, newCourse]);
      })
    );
  }

  updateCourse(courseId: number, updates: Partial<Course>): Observable<Course> {
    return this.http.put<Course>(`/api/courses/${courseId}`, updates).pipe(
      tap(updatedCourse => {
        this._courses.update(courses => 
          courses.map(course => 
            course.id === courseId ? { ...course, ...updatedCourse } : course
          )
        );
      })
    );
  }

  deleteCourse(courseId: number): Observable<void> {
    return this.http.delete<void>(`/api/courses/${courseId}`).pipe(
      tap(() => {
        this._courses.update(courses => 
          courses.filter(course => course.id !== courseId)
        );
        if (this._selectedCourseId() === courseId) {
          this._selectedCourseId.set(null);
        }
      })
    );
  }
}

Phase 3: Complex State with Side Effects

NGXS Pattern (Before)

// order.state.ts
@State<OrderStateModel>({
  name: 'order',
  defaults: {
    items: [],
    total: 0,
    discount: 0,
    shipping: 0,
    loading: false,
    error: null,
    processing: false
  }
})
@Injectable()
export class OrderState {
  @Selector()
  static items(state: OrderStateModel): OrderItem[] {
    return state.items;
  }

  @Selector()
  static subtotal(state: OrderStateModel): number {
    return state.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  }

  @Selector()
  static total(state: OrderStateModel): number {
    const subtotal = OrderState.subtotal(state);
    return subtotal - state.discount + state.shipping;
  }

  @Action(AddItem)
  addItem(ctx: StateContext<OrderStateModel>, action: AddItem) {
    const state = ctx.getState();
    const existingItem = state.items.find(item => item.productId === action.item.productId);
    
    if (existingItem) {
      ctx.patchState({
        items: state.items.map(item => 
          item.productId === action.item.productId 
            ? { ...item, quantity: item.quantity + action.item.quantity }
            : item
        )
      });
    } else {
      ctx.patchState({
        items: [...state.items, action.item]
      });
    }
  }

  @Action(Checkout)
  checkout(ctx: StateContext<OrderStateModel>) {
    ctx.patchState({ processing: true, error: null });
    const state = ctx.getState();
    
    return this.orderService.createOrder({
      items: state.items,
      total: OrderState.total(state)
    }).pipe(
      tap(response => {
        ctx.patchState({
          items: [],
          processing: false
        });
        this.router.navigate(['/order-success', response.orderId]);
      }),
      catchError(error => {
        ctx.patchState({
          processing: false,
          error: error.message
        });
        return EMPTY;
      })
    );
  }
}

Signals Pattern (After)

// order.service.ts
@Injectable({ providedIn: 'root' })
export class OrderService {
  private _items = signal<OrderItem[]>([]);
  private _discount = signal(0);
  private _shipping = signal(0);
  private _loading = signal(false);
  private _error = signal<string | null>(null);
  private _processing = signal(false);

  // Computed properties
  readonly items = this._items.asReadonly();
  readonly discount = this._discount.asReadonly();
  readonly shipping = this._shipping.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();
  readonly processing = this._processing.asReadonly();

  readonly subtotal = computed(() => 
    this._items().reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );

  readonly total = computed(() => 
    this.subtotal() - this._discount() + this._shipping()
  );

  readonly itemCount = computed(() => 
    this._items().reduce((sum, item) => sum + item.quantity, 0)
  );

  constructor(
    private http: HttpClient,
    private router: Router,
    private notificationService: NotificationService
  ) {
    // Effect for analytics tracking
    effect(() => {
      const items = this._items();
      if (items.length > 0) {
        this.analytics.trackCartUpdate({
          itemCount: this.itemCount(),
          total: this.subtotal()
        });
      }
    });

    // Effect for auto-save to localStorage
    effect(() => {
      const items = this._items();
      localStorage.setItem('cart_items', JSON.stringify(items));
    });
  }

  addItem(item: OrderItem) {
    this._items.update(items => {
      const existingItem = items.find(i => i.productId === item.productId);
      if (existingItem) {
        return items.map(i => 
          i.productId === item.productId 
            ? { ...i, quantity: i.quantity + item.quantity }
            : i
        );
      }
      return [...items, item];
    });
  }

  updateQuantity(productId: number, quantity: number) {
    if (quantity <= 0) {
      this.removeItem(productId);
      return;
    }

    this._items.update(items => 
      items.map(item => 
        item.productId === productId 
          ? { ...item, quantity }
          : item
      )
    );
  }

  removeItem(productId: number) {
    this._items.update(items => items.filter(item => item.productId !== productId));
  }

  clearCart() {
    this._items.set([]);
    this._discount.set(0);
    this._shipping.set(0);
  }

  applyDiscount(discountCode: string): Observable<boolean> {
    this._loading.set(true);
    this._error.set(null);

    return this.http.post<DiscountResponse>('/api/discounts/validate', { code: discountCode }).pipe(
      tap(response => {
        this._discount.set(response.amount);
        this._loading.set(false);
        this.notificationService.success('Discount applied successfully!');
      }),
      catchError(error => {
        this._error.set(error.message);
        this._loading.set(false);
        return of(false);
      })
    );
  }

  checkout(): Observable<OrderResponse | null> {
    this._processing.set(true);
    this._error.set(null);

    const orderRequest: CreateOrderRequest = {
      items: this._items(),
      discount: this._discount(),
      shipping: this._shipping(),
      total: this.total()
    };

    return this.http.post<OrderResponse>('/api/orders', orderRequest).pipe(
      tap(response => {
        this.clearCart();
        this._processing.set(false);
        this.notificationService.success('Order placed successfully!');
        this.router.navigate(['/order-success', response.orderId]);
        return response;
      }),
      catchError(error => {
        this._error.set(error.message);
        this._processing.set(false);
        return of(null);
      })
    );
  }
}

Phase 4: Async Operations and API Integration

Advanced Pattern with Caching

// api-state.service.ts
@Injectable({ providedIn: 'root' })
export class ApiStateService<T> {
  private _data = signal<T[]>([]);
  private _loading = signal(false);
  private _error = signal<string | null>(null);
  private _lastUpdated = signal<Date | null>(null);

  readonly data = this._data.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly error = this._error.asReadonly();
  readonly lastUpdated = this._lastUpdated.asReadonly();

  readonly isEmpty = computed(() => this._data().length === 0);
  readonly isStale = computed(() => {
    const lastUpdated = this._lastUpdated();
    return !lastUpdated || (Date.now() - lastUpdated.getTime()) > 5 * 60 * 1000; // 5 minutes
  });

  constructor(
    private endpoint: string,
    private http: HttpClient
  ) {}

  fetch(refresh = false): Observable<T[]> {
    if (!refresh && !this.isStale()) {
      return of(this._data());
    }

    this._loading.set(true);
    this._error.set(null);

    return this.http.get<T[]>(this.endpoint).pipe(
      tap(data => {
        this._data.set(data);
        this._loading.set(false);
        this._lastUpdated.set(new Date());
      }),
      catchError(error => {
        this._error.set(error.message);
        this._loading.set(false);
        return of([]);
      })
    );
  }

  add(item: T): Observable<T> {
    return this.http.post<T>(this.endpoint, item).pipe(
      tap(newItem => {
        this._data.update(data => [...data, newItem]);
        this._lastUpdated.set(new Date());
      })
    );
  }

  update(id: number, updates: Partial<T>): Observable<T> {
    return this.http.put<T>(`${this.endpoint}/${id}`, updates).pipe(
      tap(updatedItem => {
        this._data.update(data => 
          data.map(item => (item as any).id === id ? { ...item, ...updatedItem } : item)
        );
        this._lastUpdated.set(new Date());
      })
    );
  }

  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.endpoint}/${id}`).pipe(
      tap(() => {
        this._data.update(data => data.filter(item => (item as any).id !== id));
        this._lastUpdated.set(new Date());
      })
    );
  }

  invalidateCache() {
    this._lastUpdated.set(null);
  }
}

// Usage example
@Injectable({ providedIn: 'root' })
export class UsersService extends ApiStateService<User> {
  constructor(http: HttpClient) {
    super('/api/users', http);
  }

  // Additional user-specific methods
  search(query: string): Observable<User[]> {
    return this.http.get<User[]>(`/api/users/search?q=${query}`);
  }

  // Computed property for active users
  readonly activeUsers = computed(() => 
    this.data().filter(user => user.isActive)
  );
}

Phase 5: Hybrid Approach - NGXS + Signals

For large applications, a hybrid approach can be effective during the transition period.

Hybrid Service Pattern

// hybrid-state.service.ts
@Injectable({ providedIn: 'root' })
export class HybridStateService {
  private _localState = signal<any>({});

  // Create signal from NGXS selector
  createSignalFromStore<T>(selector: (state: any) => T): Signal<T> {
    return toSignal(this.store.select(selector), { initialValue: null as any });
  }

  // Create computed from NGXS store
  createComputedFromStore<T>(
    dependencies: Signal<any>[],
    computeFn: (...deps: any[]) => T
  ): Signal<T> {
    return computed(() => computeFn(...dependencies.map(dep => dep())));
  }

  // Action dispatcher wrapper
  dispatchAction(action: any) {
    this.store.dispatch(action);
  }

  // Local state management
  setLocalState(key: string, value: any) {
    this._localState.update(state => ({ ...state, [key]: value }));
  }

  getLocalState<T>(key: string): Signal<T> {
    return computed(() => this._localState()[key] as T);
  }

  constructor(private store: Store) {}
}


// Usage example
@Component({
  selector: 'app-hybrid-component',
  template: `
    <div>
      <p>NGXS User: {{ ngxsUser$ | async?.name }}</p>
      <p>Signals User: {{ signalsUser()?.name }}</p>
      <p>Combined: {{ combinedGreeting() }}</p>
    </div>
  `
})

export class HybridComponent {
  // NGXS Observable
  ngxsUser$ = this.store.select(AuthState.user);
  
  // Convert to Signal
  signalsUser = this.hybridState.createSignalFromStore(AuthState.user);
  
  // Local state
  localMessage = this.hybridState.getLocalState<string>('message');
  
  // Computed combining NGXS and local state
  combinedGreeting = computed(() => {
    const user = this.signalsUser();
    const message = this.localMessage();
    return user ? `${message}, ${user.name}!` : 'Loading...';
  });

  constructor(private hybridState: HybridStateService) {
    // Set initial local state
    this.hybridState.setLocalState('message', 'Hello');
  }
}

Best Practices for Migration

1. Incremental Migration Strategy

// migration.service.ts
@Injectable({ providedIn: 'root' })
export class MigrationService {
  private migrationFlags = signal<Record<string, boolean>>({});

  enableFeature(featureName: string) {
    this.migrationFlags.update(flags => ({ ...flags, [featureName]: true }));
  }

  isFeatureEnabled(featureName: string): boolean {
    return this.migrationFlags()[featureName] || false;
  }

  // Gradual feature flagging
  useSignalsForAuth(): boolean {
    return this.isFeatureEnabled('auth-signals');
  }

  useSignalsForCourses(): boolean {
    return this.isFeatureEnabled('courses-signals');
  }
}

2. Testing Migration

// Migration tests
describe('Auth Service Migration', () => {
  let ngxsStore: Store;
  let signalsAuthService: AuthService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      // ... configuration
    });
    ngxsStore = TestBed.inject(Store);
    signalsAuthService = TestBed.inject(AuthService);
  });

  it('should maintain compatibility during migration', () => {
    // Test both implementations work identically
    const testUser = { id: 1, name: 'Test User' };
    
    // NGXS approach
    ngxsStore.dispatch(new LoginSuccess(testUser));
    const ngxsUser = ngxsStore.selectSnapshot(AuthState.user);
    
    // Signals approach
    signalsAuthService.login(testUser);
    const signalsUser = signalsAuthService.user();
    
    expect(ngxsUser).toEqual(signalsUser);
  });
});

3. Performance Monitoring

// performance.service.ts
@Injectable({ providedIn: 'root' })
export class PerformanceService {
  private metrics = signal<StateMetrics>({});

  trackStatePerformance(featureName: string, operation: string, duration: number) {
    this.metrics.update(metrics => ({
      ...metrics,
      [`${featureName}-${operation}`]: duration
    }));
  }

  getMetrics(): Signal<StateMetrics> {
    return this.metrics.asReadonly();
  }

  // Performance comparison
  comparePerformance(): Observable<string> {
    return this.metrics().pipe(
      map(metrics => {
        const ngxsAvg = this.calculateAverage(metrics, 'ngxs');
        const signalsAvg = this.calculateAverage(metrics, 'signals');
        return `Signals is ${(ngxsAvg / signalsAvg).toFixed(2)}x faster`;
      })
    );
  }
}

Common Migration Challenges and Solutions

1. Selectors with Complex Logic

NGXS Pattern:

@Selector()
static complexSelector(state: MyStateModel): ComplexType {
  return state.items
    .filter(item => item.active && item.category === 'premium')
    .sort((a, b) => b.priority - a.priority)
    .slice(0, 5)
    .map(item => transformItem(item));
}

Signals Pattern:

readonly complexComputed = computed(() => 
  this._items()
    .filter(item => item.active && item.category === 'premium')
    .sort((a, b) => b.priority - a.priority)
    .slice(0, 5)
    .map(item => this.transformItem(item))
);

2. Action Chaining and Side Effects

NGXS Pattern:

@Action(FirstAction)
firstAction(ctx: StateContext<MyStateModel>) {
  return this.service.doSomething().pipe(
    tap(result => ctx.dispatch(new SecondAction(result)))
  );
}

Signals Pattern:

firstAction() {
  this.service.doSomething().subscribe(result => {
    this.secondAction(result);
  });
}

private secondAction(result: any) {
  this._someState.update(state => ({ ...state, result }));
}

3. State Persistence

NGXS with Storage Plugin:

@NgModule({
  imports: [
    NgxsModule.forRoot([MyState]),
    NgxsStoragePlugin.forRoot({
      key: 'myState'
    })
  ]
})
export class AppModule {}

Signals with Effect:

constructor() {
  // Load from storage
  const stored = localStorage.getItem('myState');
  if (stored) {
    this._myState.set(JSON.parse(stored));
  }

  // Save to storage on changes
  effect(() => {
    const state = this._myState();
    localStorage.setItem('myState', JSON.stringify(state));
  });
}

Migration Checklist

Pre-Migration Planning

  • Audit current NGXS states and their complexity
  • Identify migration priority (simple states first)
  • Create feature flags for gradual rollout
  • Set up performance monitoring
  • Prepare test coverage for existing functionality

Migration Execution

  • Create signal-based services for simple states
  • Update components to use signals
  • Implement hybrid approach for complex scenarios
  • Migrate computed values and selectors
  • Handle async operations and side effects
  • Update testing strategies

Post-Migration Validation

  • Performance benchmarking
  • Bundle size analysis
  • User acceptance testing
  • Remove NGXS dependencies
  • Update documentation
  • Team training on signals

Conclusion

Migrating from NGXS to Angular Signals offers significant benefits in performance, developer experience, and bundle size. However, it requires careful planning and incremental execution.

Key Takeaways:

  1. Start Simple: Begin with component-level state before tackling complex shared state
  2. Use Hybrid Approach: Leverage both NGXS and Signals during transition
  3. Monitor Performance: Track improvements in bundle size and runtime performance
  4. Test Thoroughly: Ensure behavioral equivalence during migration
  5. Educate Team: Provide training on Signals patterns and best practices

The migration journey is an investment in modern Angular development practices. With proper planning and execution, teams can successfully transition to Signals while maintaining application stability and improving overall performance.

Remember that Signals complement rather than replace all state management needs. For complex enterprise applications, consider maintaining NGXS for certain scenarios while adopting Signals for others, creating a hybrid approach that serves your specific requirements.