Component-First State Management for Angular Standalone Components

Colum Ferry - Jan 16 '22 - - Dev Community

Introduction

In 2021, Angular announced an RFC (Request For Comments) for Standalone Components. Optional NgModules have been a frequent ask from the framework's community since their introduction in Angular 2-rc.5. Standalone Components (and Directives and Pipes) are Angular's answer to this request. It paves the way for our Angular apps to be built purely with Components.

However, over the years we have built architectural patterns for Angular taking into account that NgModules exist and are the driving force of current Angular apps. With NgModules becoming optional, we need to think about new patterns that can help us to build the same resiliant and scalable apps, but using a simpler mental model of our apps.

This is where Component-First comes in to play. It is a collection of patterns for designing Angular apps, once we have Standalone Components, that emphasises that Components, as the main source of user interaction, are the source of truth for our apps.

We should be able to link all the components in our app together and know exactly how our app works.
There’ll be no magic happening off in some obscure module somewhere.

To achieve this, components need to manage their own routing and state.

In this article, we'll explore an approach to State Management that allows components to control their state and be their own source of truth.


If you're interested in seeing how Routing changes with Standalone Components, read the article I wrote on the matter below

Component-First Architecture with Angular and Standalone Components


Why do we need a different approach?

In the current state of Angular, the framework does not ship with a built-in solution to state management. It does provide the building blocks, but it does not take an opinionated stance on how to manage the state in your app. The Angular community has stepped in to fill that gap in the ecosystem with the creation of packages such as

  • NgRx
  • NgXs
  • ... Others that I have not listed.

However, the ones I have listed, arguably the most popular in the ecosystem, rely on NgModules to instantiate the State Management Solution.

If we want to move to a truly NgModule-less developer experience, we need to transition away from any solution that relies on NgModule, otherwise we will always be coupling our components to NgModules. This coupling will continue to be more and more difficult to remove them over time. It also complicates the modelling of our system. Our state will be created and handled in a separate location from our components. This increased obscurity in how our state gets managed makes it more difficult for us to evaluate our components and how they function.

NgRx has already taken steps in the direction that I feel is perfect for a Standalone Components world. They created a package called Component Store which allows Components to manage their own state. It works and it is a great solution! If you've used it before and you're comfortable with RxJS, use it!

However, I have created a package, @component-first/redux, that implements the Redux pattern in a local component store that does not use RxJS that we can also use to achieve the same effect.

In the rest of this article, I'll illustrate how we can use this package to manage the state within our apps for Standalone Component.

Creating and using a Store for Standalone Components

Let's take the following component as an example. It will be a basic ToDo List component that manages its own list of todos and allow actions such as add and delete.

Our barebones component, without a store, should look similar to this:

@Component({
  standalone: true,
  selector: 'todo-list',
  template: `<input #newTodo type="text" /><button
      (click)="addTodo(newTodo.value)"
    >
      Add
    </button>
    <ul>
      <li *ngFor="let todo of todos">
        {{ todo.name }} <button (click)="deleteTodo(todo.id)">Delete</button>
      </li>
    </ul>`,
  imports: [CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent implements OnInit {
  todos = {};
  incrementId = 1;

  constructor() {}

  ngOnInit() {
    this.todos = {
      0: { name: 'Example Todo' },
    };
  }

  addTodo(todo: string) {
    this.todos[this.incrementId++] = { name: todo };
  }

  deleteTodo(id: number) {
    delete this.todos[id];
  }
}
Enter fullscreen mode Exit fullscreen mode

It's a pretty straightforward component that is internally managing it's own state. Creating a Store for it may be overkill, but it'll be a good example to showcase the component store.

First, we need to create the store. We create a file beside our component called todo-list.component.store.ts and it should look like this:

import { Store } from '@component-first/redux';
import { ChangeDetectorRef, Injectable } from '@angular/core';

// We need to define the shape of our state
interface TodoListState {
  todos: Record<string, { name: string }>;
  incrementId: number;
}

// We only want to inject our Store in our component, so do not provide in root
// We also need to extend the Store class from @component-first/redux
@Injectable()
export class TodoListComponentStore extends Store<TodoListState> {
  // We define actions and store them on the class so that they can be reused
  actions = {
    addTodo: this.createAction<{ name: string }>('addTodo'),
    deleteTodo: this.createAction<{ id: number }>('deleteTodo'),
  };

  // We also define selectors that select slices of our state
  // That can be used by our components
  selectors = {
    todos: this.select((state) => state.todos),
  };

  // We need a function that our Component can call on instantiation that
  // will create our store with our intiial state and the change detector ref
  create(cd: ChangeDetectorRef) {
    const initialState = {
      todos: {
        1: { name: 'Example Todo' },
      },
      incrementId: 2,
    };

    this.init(cd, initialState);

    // We then define the reducers for our store
    this.createReducer(this.actions.addTodo, (state, { name }) => ({
      ...state,
      todos: {
        ...state.todos,
        [state.incrementId]: { name },
      },
      incrementId: state.incremenet + 1,
    }));

    this.createReducer(this.actions.deleteTodo, (state, { id }) => ({
      ...state,
      todos: {
        ...state.todos,
        [id]: undefined,
      },
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

It's as simple as that, and now our state management is self contained in a class and file that lives right beside our component. Now, lets modify our component to use our new store:

import { SelectorResult, LatestPipe } from '@component-first/redux';
import { TodoListComponentStore } from './todo-list.component.store';

@Component({
  standalone: true,
  selector: 'todo-list',
  template: `<input #newTodo type="text" /><button
      (click)="addTodo(newTodo.value)"
    >
      Add
    </button>
    <ul>
      <li *ngFor="let todo of todos | latest">
        {{ todo.name }} <button (click)="deleteTodo(todo.id)">Delete</button>
      </li>
    </ul>`,
  imports: [LatestPipe, CommonModule],
  providers: [TodoListComponentStore],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent implements OnInit {
  todos: SelectorResult<Record<string, { name: string }>>;

  constructor(
    private cd: ChangeDetectorRef,
    private store: TodoListComponentStore
  ) {
    this.store.create(cd);
  }

  ngOnInit() {
    this.todos = this.store.selectors.todos;
  }

  addTodo(name: string) {
    this.store.dispatchAction(this.store.actions.addTodo, { name });
  }

  deleteTodo(id: number) {
    this.store.dispatchAction(this.store.actions.deleteTodo, { id });
  }
}
Enter fullscreen mode Exit fullscreen mode

It's pretty straightforward to use our new Store and it follows an API we are all somewhat familiar with providing you have used NgRx in the past. We did have to introduce a new pipe, latest, that will always fetch the latest value from the store on a Change Detection Cycle.

Advanced Techniques

Effects

The Store also supports Effects. This can be useful in a wide variety of situations, however, lets modify our TodoListComponentStore to have an effect that will fetch our Todo list from an API.

import { Store } from '@component-first/redux';
import { ChangeDetectorRef, Injectable } from '@angular/core';

interface TodoListState {
  todos: Record<string, { name: string }>;
  incrementId: number;
}

@Injectable()
export class TodoListComponentStore extends Store<TodoListState> {
  actions = {
    addTodo: this.createAction<{ name: string }>('addTodo'),
    deleteTodo: this.createAction<{ id: number }>('deleteTodo'),
    // We need a new action to load the todos from an API
    loadTodos: this.createAction('loadTodos'),
  };

  selectors = {
    todos: this.select((state) => state.todos),
  };

  create(cd: ChangeDetectorRef) {
    const initialState = {
      todos: {},
      incrementId: 0,
    };

    this.init(cd, initialState);

    this.createReducer(this.actions.addTodo, (state, { name }) => ({
      ...state,
      todos: {
        ...state.todos,
        [state.incrementId]: { name },
      },
      incrementId: state.incremenet + 1,
    }));

    this.createReducer(this.actions.deleteTodo, (state, { id }) => ({
      ...state,
      todos: {
        ...state.todos,
        [id]: undefined,
      },
    }));

    // We create an effect that will occur when the LoadTodos action is dispatched
    this.createEffect(this.actions.loadTodos, () => {
      // It will make an API call
      fetch('api/todos').then((response) => {
        const todos = response.json();
        todos.forEach((todo) =>
          // Then it will dispatch our existing AddTodo action to add the todos
          this.dispatchAction(this.actions.addTodo, todo)
        );
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have added our effect, we can take advantage of it in our component by disptaching an action:

import { SelectorResult, LatestPipe } from '@component-first/redux';
import { TodoListComponentStore } from './todo-list.component.store';

@Component({
  standalone: true,
  selector: 'todo-list',
  template: `<input #newTodo type="text" /><button
      (click)="addTodo(newTodo.value)"
    >
      Add
    </button>
    <ul>
      <li *ngFor="let todo of todos | latest">
        {{ todo.name }} <button (click)="deleteTodo(todo.id)">Delete</button>
      </li>
    </ul>`,
  imports: [LatestPipe, CommonModule],
  providers: [TodoListComponentStore],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoListComponent implements OnInit {
  todos: SelectorResult<Record<string, { name: string }>>;

  constructor(
    private cd: ChangeDetectorRef,
    private store: TodoListComponentStore
  ) {
    this.store.create(cd);
  }

  ngOnInit() {
    this.todos = this.store.selectors.todos;
    // OnInit, load the todos!
    this.store.dispatchAction(this.store.actions.loadTodos);
  }

  addTodo(name: string) {
    this.store.dispatchAction(this.store.actions.addTodo, { name });
  }

  deleteTodo(id: number) {
    this.store.dispatchAction(this.store.actions.deleteTodo, { id });
  }
}
Enter fullscreen mode Exit fullscreen mode

Global / Shared State

Now that we don't have NgModules, how can we share a store between components?

Note: I wouldn't recommend it, but it does have it's uses, such as a global notification system.

In Component-First, because all our components are children or siblings of each other, we can take advantage of Angular's Injection Tree and simply inject a parent's Store into our child component.

Let's say we had a component, TodoComponent, that was a child to TodoListComponent, then we could do the following:

@Component({
    ...
})
export class TodoComponent {

    constructor(private store: TodoListComponentStore) {}
}
Enter fullscreen mode Exit fullscreen mode

I'd advise caution with this approach as it forces a coupling between TodoListComponent and TodoComponent where TodoComponent must always be a child of TodoListComponent. In some scenarios, this makes logical sense, but it's something to be aware of!

Play with the package

The @component-first/redux package is available on npm and you can use it to experiement with. Just note that the LatestPipe is currently not Standalone in the package (I do not want to ship the Standalone Shim provided by Angular), so you will have to add the LatestPipe to an NgModule's declarations. When Standalone Components arrive, I will make the pipe Standalone!


I hope this article helps to get you excited for Standalone Components and helps you start to think about some approaches we can take to architecture when they do arrive!

If you have any questions, feel free to ask below or reach out to me on Twitter: @FerryColum.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .