This content originally appeared on DEV Community and was authored by Colum Ferry
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
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];
}
}
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,
},
}));
}
}
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 });
}
}
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)
);
});
});
}
}
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 });
}
}
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) {}
}
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.
This content originally appeared on DEV Community and was authored by Colum Ferry
Colum Ferry | Sciencx (2022-01-16T13:42:57+00:00) Component-First State Management for Angular Standalone Components. Retrieved from https://www.scien.cx/2022/01/16/component-first-state-management-for-angular-standalone-components/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.