Photo by Shutter Speed on Unsplash


The evolution of state management in Angular has been remarkable. The journey started with raw Observables scattered across global services, moved through modular store solutions like the signal store, and touched on more heavyweight Redux-style architectures. Along the way, Angular developers have experimented with countless approaches to keeping application data consistent.

Yet recent major Angular releases introduced a new kind of reactive variable called signals. This primitive brings synchronous reactivity to data handling, offering a developer experience that previously required a dedicated state management library.

This piece walks through managing state without external dependencies, using a practical scenario to build a Redux-style architecture from scratch. For hands-on practice, check out the dev.local-state-management repository on GitHub.

The Purpose of Managing State

If you already are familiar with state management, you can jump to the next section "Implementing State Management for a Shopping Cart".

State management comes up frequently in enterprise-scale applications, but it's worth questioning whether we genuinely need it. After all, components can simply hold properties and handle interactions directly — a straightforward approach in many cases.

That instinct isn't wrong. For small components or small projects, plain component properties remain perfectly adequate. State management functions as a tool, and tools exist to address specific problems — sharing data across many components, offloading complexity from components, keeping components focused on presentation rather than application logic, and similar challenges.

If you're not familiar with it, a lot of things in software engineering often falls into the Law of the instrument, which can be summarized as "If your only tool is a hammer then every problem looks like a nail". In this case, state management is not always necessary as its a tradeoff between increased complexity and data consistency.

There's also a testing edge: when logic lives in stores, component tests only need a mocked store to exercise UI behavior, keeping test suites lean.

Redux in a Nutshell

The Redux pattern underpins most state management systems in use today.

Initially gaining traction as a React library in 2015, Redux provides both a library and a methodology for predictable state handling.

Its architecture relies on several distinct roles, each assigned a single responsibility, forming a one-way data flow:

  1. The component reads the state via selectors that expose specific slices of the data.
  2. Components emit actions to signal that an event occurred.
  3. Actions are consumed by logic blocks called reducers, which translate them into state updates.
  4. When reducers modify the state, selectors recalculate, prompting the view to refresh.
  5. Side effects may fire as a reaction to an action announcement.

A visual representation of this flow looks like this:

Redux Overview

State Management Inside Angular

Typical Angular projects reach for a state management library—most often NgRx, NGXS, or rxAngular—though the choice largely comes down to team preference, as each offers solid building blocks.

But Angular signals shifting things. Now it's possible to achieve a Redux-inspired state management approach with this reactive primitive, bypassing third-party dependencies.

Let's dive into how that works.

Implementing State Management for a Shopping Cart

Case Study: Le Shop

To demonstrate state management, we'll use a fictional French grocery storefront as our example.

Use Case Demo

Currently, the application supports the following interactions:

  • Viewing the cart along with its running total
  • Adding items to the cart via the Add to Cart button
  • Emptying the entire cart

All of this behavior is currently housed within the parent component:

@Component({
  selector: 'app-root',
  imports: [ProductCardComponent, CartComponent],
  template: `
    <h2>Le Shop 🇫🇷</h2>

    <div>
      <app-cart [cart]="cart()" (clear)="clearCart()" />

      @if (productsResource.value(); as products) {
        <section>
          @for (product of products; track product.name) {
            <app-product-card [product]="product" (addedToCard)="addItem(product)" />
          }
        </section>
      }
    </div>
  `,
  styles: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  readonly productsService = inject(ProductsService);

  readonly cart = signal<CartItem[]>([]);

  readonly productsResource = resource({
    loader: this.productsService.getProductsStock,
  });

  addItem(product: Product): void {
    this.cart.update((cartItems) => {
      const currentQuantity = cartItems.find(({ name }) => name === product.name)?.quantity ?? 0;

      return cartItems
        .filter(({ name }) => name !== product.name)
        .concat([{ ...product, quantity: currentQuantity + 1 }]);
    });
  }

  clearCart(): void {
    this.cart.set([]);
  }
}
Enter fullscreen mode Exit fullscreen mode

Analyzing the Current Structure

As previously discussed, keeping logic centralized like this is perfectly acceptable when the application's scope remains small and manageable.

But as new features emerge, maintaining organization becomes increasingly difficult. Consider how you would go about adding, in sequence:

  • A notification that appears when a product is added to the cart
  • Support for discount codes
  • A button for users to be alerted when a sold-out product is back in stock

Beyond the business requirements, there are also technical drawbacks. The component carries too many responsibilities—handling cart logic, rendering data, and coordinating the UI all at once.

Because all logic is trapped in this single component, sharing it with other parts of the app is out of the question. Adding a compact cart icon with a total to the navigation bar would be a considerable chore.

In essence, this component currently:

  • Accumulates more code and responsibility with each new feature
  • Blurs the line between business logic and UI orchestration
  • Is far harder to unit test than it should be
  • Offers no reusable state access

Thankfully, we're familiar with the Redux pattern, and we're about to bring that structure into our project!

Creating the Store

We'll put the store together piece by piece, beginning with the state definition.

Defining the State

Before getting into the code, it's helpful to spell out what our current state consists of. Off the top of my head, there are a few key ingredients:

  • Whether data is currently being loaded
  • The catalogue of available products
  • The items that have been placed in the cart

Handling an error state for a failed fetch would also be a sensible addition, but we'll keep this example concise. Feel free to branch out and add it yourself.

With that in mind, we can draft a type definition for our state, and hold it inside a signal for synchronous reads as well as seamless reactive updates:

export interface AppState {
  addedProducts: CartItem[];
  isLoading: boolean;
  products: ListedProduct[];
}

@Injectable()
export class AppStore {
  readonly #state = signal<AppState>({
    addedProducts: [],
    isLoading: false,
    products: [],
  });
}
Enter fullscreen mode Exit fullscreen mode

A solid start, but we still lack a way to interact with our store. Let's tackle that next.

Adding Events

Events represent occurrences that have taken place. They typically share two common traits:

  • They are asynchronous and have no predictable order or timing
  • They carry a payload, which can repeat—like adding the same product to the cart twice, which produces identical events

Given their asynchronous and potentially duplicative nature, Observables are a natural fit for representing events. These event streams serve as our sources.

It's possible to mimic this with signals by making the equality check always return false, which would permit the same payload to be emitted more than once. Personally, I'd rather avoid this since it undercuts one of the fundamental guarantees of signals.

For this application, we can identify the following potential events:

  • The cart was cleared
  • A product was added to the cart
  • Products were loaded into the store

Each of these can be emitted (with its respective payload) via the following sources:

readonly #cartCleared = new Subject<void>();
readonly #productAddedToCart = new Subject<{ product: Product }>();
readonly #productsLoaded = new Subject<{ products: ListedProduct[] }>();
readonly #productsLoading = new Subject<void>();
Enter fullscreen mode Exit fullscreen mode

Notice that all events are named in the past tense. This emphasizes that they represent something that has already happened.

However, we shouldn't expose these event sources directly—they're internal to the store's orchestration. We don't want a component unilaterally telling the store which products were loaded, as that's not the component's job.

Instead, we provide a public API that exposes only specific actions the outside world can execute. These are known as actions:

addProductToCart(product: Product): void {
  this.#productAddedToCart.next({ product });
}

clearCart(): void {
  this.#cartCleared.next();
}
Enter fullscreen mode Exit fullscreen mode

We can now emit events, but we haven't yet associated them with state changes. This is where reducers come in.

Creating Reducers

Reducers are functions that listen for events and update state in response to their payloads.

It's crucial to understand that reducers are the only part of the store permitted to modify state.

Given that our events are Observables, reducers can simply be Subscriptions to each event stream:

constructor() {
  this.#cartCleared
    .pipe(takeUntilDestroyed())
    .subscribe(() => this.#state.update((state) => ({ ...state, addedProducts: [] })));

  this.#productAddedToCart.pipe(takeUntilDestroyed()).subscribe(({ product }) =>
    this.#state.update((state) => {
      const currentQuantity =
        state.addedProducts.find(({ name }) => name === product.name)?.quantity ?? 0;

      const updatedCart = state.addedProducts
        .filter(({ name }) => name !== product.name)
        .concat([{ ...product, quantity: currentQuantity + 1 }]);

      return { ...state, addedProducts: updatedCart };
    }),
  );

  this.#productsLoaded
    .pipe(takeUntilDestroyed())
    .subscribe(({ products }) =>
      this.#state.update((state) => ({ ...state, products, isLoading: false })),
    );
}
Enter fullscreen mode Exit fullscreen mode

We're close! However, the component cannot tell the store which products were loaded, so we need a mechanism to trigger that programmatically.

Introducing Effects

Effects are simply events that launch other events. In our case, we can treat the resource's lifecycle changes—like loading state shifts or value updates—as triggers and respond with the proper events.

We'll start by obtaining the resource:

readonly #productsService = inject(ProductsService);

readonly #productsResource = resource({
  loader: this.#productsService.getProductsStock,
});
Enter fullscreen mode Exit fullscreen mode

We can then subscribe to its changes and dispatch the appropriate events:

effect(() => {
  const products = this.#productsResource.value();

  if (products) {
    this.#productsLoaded.next({ products });
  }
});

effect(() => {
  const isLoading = this.#productsResource.isLoading();

  if (isLoading) {
    this.#productsLoading.next();
  }
});
Enter fullscreen mode Exit fullscreen mode

It's a nice coincidence that the signals package exports an effect function, which is exactly what we need to propagate effects.

With that, our state now encapsulates the application's business logic, with data moving in a single direction:

  • User actions or effects can trigger actions
  • actions emit events
  • events are intercepted by reducers
  • reducers update the state

Our state looks robust, but there's one missing piece: no one outside the store can read its contents.

Exposing Selectors

Selectors provide reactive access to slices of the state.

For instance, we need to retrieve the current cart items and observe them change as items are added or removed.

Our state is already reactive since it rests on a signal, and creating derived, read-only views of a signal is precisely what a computed is designed for.

Consequently, defining selectors is straightforward:

readonly addedProducts = computed(() => this.#state().addedProducts);
readonly products = computed(() => this.#state().products);
Enter fullscreen mode Exit fullscreen mode

We aren't writing a selector for isLoading in this particular example, but don't let that stop you from adding one for your own needs!

Our store now checks all the boxes for the Redux pattern. Revisiting the earlier diagram, here's how it applies to our implementation:

Redux for the cart store

Now that we have a fully functional store with well-defined ways to interact, we can strip the business logic out of the component and leave it to the store.

Using The Store

Before we put the store to use, don't forget to provide it like any other service. In this example, we'll provide it directly within the AppComponent, which treats it as a component store. For app-wide state, you could instead use @Injectable({ providedIn: 'root' }) or register it within the appConfig.

@Component({
  selector: 'app-root',
  imports: [ProductCardComponent, CartComponent],
  template: `...`,
  styles: `...`,
  providers: [AppStore],  // 👈 Provided here
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  readonly store = inject(AppStore);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

With the store registered, we can swap out all the business logic and replace it with the store's public properties:

@Component({
  selector: 'app-root',
  imports: [ProductCardComponent, CartComponent],
  template: `
    <h2>Le Shop 🇫🇷</h2>

    <div>
      <app-cart [cart]="store.addedProducts()" (clear)="clearCart()" />

      @if (store.products(); as products) {
        <section>
          @for (product of products; track product.name) {
            <app-product-card [product]="product" (addedToCard)="addItem(product)" />
          }
        </section>
      }
    </div>
  `,
  styles: `...`,
  providers: [AppStore],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  protected readonly store = inject(AppStore);

  addItem(product: Product): void {
    this.store.addProductToCart(product);
  }

  clearCart(): void {
    this.store.clearCart();
  }
}
Enter fullscreen mode Exit fullscreen mode

The component is now purely focused on UI orchestration, with all business logic neatly tucked away.

With this transformation, the problems we identified earlier are resolved:

  • Testing the component is a breeze, as we can simply mock the AppStore
  • Sharing state is now trivial—move the provider up the tree and consume the store anywhere below it
  • Adding features is easier, since the store can accommodate new logic and unify it with the existing state

Key Points

We examined what Redux entails, the issues it addresses, and how to apply this pattern within an Angular project without relying on external packages.

Besides streamlining our code, this method provides a clean division of responsibilities: components manage UI while the store handles all business logic. This separation simplifies testing—we can validate the store's behavior in isolation and substitute it entirely when running component tests.

The state stores application data in a reactive signal, while actions expose a public interface for initiating updates. Events are notifications of occurrences, emitted via Subjects, which reducers consume as the exclusive modifiers of state. Effects manage side effects and dispatch other events, and selectors offer reactive, read-only views of state using computed signals.

State management is often perceived as necessary only for large-scale applications with extensive dependencies, yet it proves remarkably straightforward. By utilizing Angular's native signals and RxJS Observables, we constructed a fully operative Redux-like store using only the framework's built-in tools.

This does not imply that libraries such as NgRx, NGXS, or RxAngular are without merit. They deliver extra capabilities, conventions, and tooling that may benefit larger teams or intricate scenarios. However, recognizing that effective state management can be achieved with straightforward Angular code lets you make considered choices about when a library is essential and when the framework's inherent features are adequate for your situation.


For those interested in deepening their knowledge of reactivity, I contributed two chapters to my book "Modern Web Development with Angular", available on Amazon 👇

A Comprehensive Guide to State Management with Vanilla Angular — figure 4