Skip to content
← Back to blog
AngularSignalsState ManagementRxJS

Angular Signals for SaaS State Management

Firas Sayah·April 22, 2026·5 min read

The State Management Problem in SaaS

SaaS frontends juggle a lot of state: the current user, their organization, subscription tier, feature flags, notifications, and the actual domain data. Traditionally, Angular developers reached for NgRx or complex RxJS patterns. Angular Signals offer a simpler approach that scales surprisingly well.

Signals Basics

A Signal is a reactive value that notifies consumers when it changes:

import { signal, computed, effect } from '@angular/core';

const count = signal(0);
const doubled = computed(() => count() * 2);

effect(() => {
  console.log(`Count is now ${count()}`);
});

count.set(1); // Logs: "Count is now 1"

Unlike Observables, Signals are synchronous. You read their value by calling them as functions. No subscriptions to manage, no async pipe, no memory leaks from forgotten unsubscribe calls.

Building a Signal-Based Store

Create lightweight stores for each feature area:

@Injectable({ providedIn: 'root' })
export class UserStore {
  private readonly _user = signal<User | null>(null);
  private readonly _loading = signal(false);

  readonly user = this._user.asReadonly();
  readonly loading = this._loading.asReadonly();
  readonly isAuthenticated = computed(() => this._user() !== null);
  readonly displayName = computed(() => this._user()?.name ?? 'Guest');

  async loadCurrentUser() {
    this._loading.set(true);
    try {
      const user = await this.api.getCurrentUser();
      this._user.set(user);
    } finally {
      this._loading.set(false);
    }
  }

  logout() {
    this._user.set(null);
  }
}

The asReadonly() method prevents external code from modifying the signal directly. Only the store's own methods can update state.

Computed Signals for Derived State

Computed signals automatically update when their dependencies change:

@Injectable({ providedIn: 'root' })

<div class="not-prose my-8 rounded-xl border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-6">
<p class="text-base font-semibold text-primary-900 dark:text-primary-100 mb-1">Cloudrix SaaS Starter ships with this pre-configured and tested.</p>
<p class="text-sm text-primary-700 dark:text-primary-300 mb-3">Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.</p>
<a href="https://demo.cloudrix.io" class="inline-flex items-center text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500">Try the live demo &rarr;</a>
</div>

export class SubscriptionStore {
  private readonly _plan = signal<Plan>('free');
  private readonly _usage = signal<UsageData>({ apiCalls: 0, storage: 0 });

  readonly plan = this._plan.asReadonly();
  readonly usage = this._usage.asReadonly();

  readonly limits = computed(() => PLAN_LIMITS[this._plan()]);

  readonly usagePercentage = computed(() => ({
    apiCalls: (this._usage().apiCalls / this.limits().maxApiCalls) * 100,
    storage: (this._usage().storage / this.limits().maxStorage) * 100,
  }));

  readonly isNearLimit = computed(() =>
    this.usagePercentage().apiCalls > 80 || this.usagePercentage().storage > 80
  );
}

LinkedSignal for Dependent State

Angular 19 introduced linkedSignal for state that resets when a source changes:

@Injectable({ providedIn: 'root' })
export class ProjectStore {
  readonly selectedOrg = signal<Org | null>(null);
  readonly projects = linkedSignal<Project[]>({
    source: this.selectedOrg,
    computation: () => [], // Reset projects when org changes
  });

  async loadProjects() {
    const orgId = this.selectedOrg()?.id;
    if (!orgId) return;
    const data = await this.api.getProjects(orgId);
    this.projects.set(data);
  }
}

Effects for Side Effects

Use effect() for operations that should run whenever state changes:

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private readonly userStore = inject(UserStore);
  private readonly subscriptionStore = inject(SubscriptionStore);

  constructor() {
    effect(() => {
      if (this.subscriptionStore.isNearLimit()) {
        this.showWarning('You are approaching your plan limits');
      }
    });
  }
}

When to Still Use RxJS

Signals are not a complete replacement for RxJS. Use RxJS for HTTP requests and their transformation pipelines, WebSocket streams, complex async coordination like debounceTime and switchMap for search inputs, and event streams from third-party libraries.

The pattern that works best is using RxJS at the data-fetching layer and converting to Signals at the store boundary:

async search(query: string) {
  const results = await firstValueFrom(
    this.http.get<SearchResult[]>(`/api/search?q=${query}`)
  );
  this._results.set(results);
}

Template Usage

Signals simplify templates significantly. No more async pipe or | undefined checks scattered everywhere:

@if (userStore.isAuthenticated()) {
  <span>Welcome, {{ userStore.displayName() }}</span>
} @else {
  <a routerLink="/login">Sign in</a>
}

Get Started Today

All of these patterns are fully implemented in SaaS Starter. Skip 2-6 weeks of setup.

F

Firas Sayah

Senior Software Engineer

Full-stack developer with 5+ years building production SaaS applications with NestJS and Angular.