thomas
Posted on December 26, 2022
Welcome to Angular challenges #5.
The aim of this series of Angular challenges is to increase your skills by practicing on real life exemples. Moreover you will have to submit your work though a PR which I or other can review; as you will do on real work project or if you want to contribute to Open Source Software.
The fifth challenge will teach you some good practices for architecture and creating scalable and readable components using Angular by refactoring a small Todo Application step by step. You will learn how to create reactive code with good separation of concerns and a great User and Developer eXperience. We will use and discover @Ngrx/component-store to create our local Component Store as well as @tomalaforge/ngrx-callstate-store to handle our loading and error states.
If you haven't done the challenge yet, I invite you to try it first by going to Angular Challenges and coming back afterward to compare your solution with mine. (You can also submit a PR that I'll review)
Starting the challenge, we have the following code.
@Component({
standalone: true,
imports: [CommonModule],
selector: 'app-root',
template: `
<div *ngFor="let todo of todos">
{{ todo.title }}
<button (click)="update(todo)">Update</button>
</div>
`,
})
export class AppComponent implements OnInit {
todos!: any[];
constructor(private http: HttpClient) {}
ngOnInit(): void {
this.http
.get<any[]>('https://jsonplaceholder.typicode.com/todos')
.subscribe((todos) => {
this.todos = todos;
});
}
update(todo: any) {
this.http
.put<any>(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
JSON.stringify({
todo: todo.id,
title: "randText(),"
body: todo.body,
userId: todo.userId,
}),
{
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
}
)
.subscribe((todoUpdated: any) => {
this.todos[todoUpdated.id - 1] = todoUpdated;
});
}
}
Issues:
- Everything is located inside a single component (http calls, state management, ui).
- No loading or error management. (not very user friendly)
- No Type checking (avoid the type any as possible)
- All properties are set declaratively.
- Void method are mutating some properties. Hard to debug as your component will grow.
- Manual subscribe without unsubscription
First, we need to move all HTTP calls to a dedicated singleton Service. This way, we can use this code everywhere by injecting it inside into our components.
// singleton service provided inside the root injector.
@Injectable({ providedIn: 'root' })
export class TodoService {
private http = inject(HttpClient);
getAllTodo = () =>
this.http.get<Todo[]>(/*...*/);
update = (id: number) =>
this.http.put<Todo>(/*...*/);
delete = (id: number) =>
this.http.delete<void>(/*...*/);
}
Setting our service providedIn: 'root'
create a unique instance and a tree-shakable service. If you want more detail, I invite you to read this article.
The second action is to create a service dedicated to our component that will hold its state. This component is called a Store
. In most application, we will have a global store. A global store holds the state for the entire application. However each component can also have a local state. that is not shared across the application. (although it can be shared among its children)
For this purpose, we will use @Ngrx/component-store
You can use any other state management libraries like Akita, RxAngular, Elf, MiniRx Store, Subject as a Service … But I will focus on Component Store of NgRx.
Let's setup our store app.store.ts
@Injectable()
export class AppStore
extends ComponentStore<{/*State definition*/}>
implements OnStateInit, OnStoreInit {
// selectors
// updaters
// effects
ngrxOnStoreInit() {
this.setState(/*initial state*/)
}
ngrxOnStateInit() {
/*initialisation logic*/
}
}
This is the minimal setup to create a CS. Let's go though it quickly:
-
OnStoreInit
andOnStateInit
are two lifecycle hooks. In order to use them, we need to provide our store using theprovideComponentStore
helper function.OnStoreInit
is called immediately after the store is instantiated.OnStateInit
is called after the state is created. Both hooks are only called once. -
selectors
are functions that extract a specific piece of state and are shareable. In RxJS, shareable observables can be called as many times as desired without being recomputed. -
updaters
lets us create pure function to update the state. In comparison withpatchState
andsetState
,updaters
have the current state as input. -
effects
handles side-effects like HTTP calls or any asynchronous task.
If you want more details or dig deeper into the documentation, you can go here.
Important Note: It's important to notice the @Injectable
decorator. We didn't add providedIn: 'root'
because we want our service to be tied to the lifecycle of the component in which it will be provided.
If you want more details on how injectable services work in Angular, you should read this article.
In order to tie our store to our component, we need to provide it at the component level:
@Component({
/*...*/
providers: [provideComponentStore(AppStore)],
/*...*/
})
Remark: As mentioned previously, we need to provide our store through provideComponentStore
to use the OnStateInit
and OnStoreInit
lifecycle hooks inside our store. We will use these hooks to do some initialization logic.
Now, let's define our state. We will store a list of our Todos, a loading and en error indicators.
interface AppState {
todos: Todo[];
loading: boolean;
error?: string;
}
Then we need some selectors to read our state and we will create a View Model for our template.
A View Model is an object that combines all the properties needed in our template. This way, we only need to subscribe to one observable and pass one stream though our component.
private readonly todos$ = this.select((state) => state.todos);
private readonly loading$ = this.select((state) => state.loading);
private readonly error$ = this.select((state) => state.error);
readonly vm$ = this.select(
{
todos: this.todos$,
loading: this.loading$,
error: this.error$,
},
{ debounce: true }
);
Remarks: Since @Ngrx v15, the definition of our viewmodel
has been simplified. This is similar to:
readonly vm$ = this.select(
this.todo$,
this.loading$,
this.error$,
(todos, loading, error) => ({
todos,
loading,
error,
}),
{ debounce: true }
);
The next step is to create all the effects needed to fetch, update and delete our todo items.
Fetch:
// fetchTodo takes no input parameters
readonly fetchTodo = this.effect<void>(
pipe(
tap(() => this.patchState({ loading: true })),
switchMap(() => this.todoService.getAllTodo().pipe(
tapResponse(
// success logic
(todos) => this.patchState({ todos, loading: false }),
// failure logic
(error: Error) =>
this.patchState({ error: error.message, loading: false })
)
)
)
)
);
We first set our loading indicator to true, then we call our HTTP service's getAllTodo
method and we the tapResponse
operator from NgRx to handle our response. This operator requires us to handle our error case and prevents us to killing the effect.
Don't forget to update the loading indicator in case of an error.
Update:
// updateTodo takes a todo id as parameter
readonly updateTodo = this.effect<number>(
pipe(
tap(() => this.patchState({ loading: true })),
switchMap((id) => this.todoService.update(id).pipe(
tapResponse(
(todo) => this.updateTodos(todo),
(error: Error) =>
this.patchState({ error: error.message, loading: false })
)
)
)
)
);
// CS updater function to replace the new Todo item inside our Todo array
private readonly updateTodos = this.updater((state, todo: Todo) => ({
error: undefined,
loading: false,
todos: state.todos.map((t) => (t.id === todo.id ? { ...todo } : t)),
}));
The update
effect logic is similar to the fetch
effect. The main difference is that the update
effect has an input id
of type number
. So we set the generic type of our effect to number
.
You can write your effect in the following way if you find it more readable:
readonly updateTodo = this.effect((id$: Obsersable<number>) =>
id$.pipe(
// ...
)
)
);
The updater
function allows us to define a pure function that takes the current state as input and produces a new, immutable state as output.
Delete: (similar to update)
readonly deleteTodo = this.effect<number>(
pipe(
tap(() => this.patchState({ loading: true })),
switchMap((id) => this.todoService.delete(id).pipe(
tapResponse(
() => this.deleteTodoState(id),
(error: Error) =>
this.patchState({ error: error.message, loading: false })
)
)
)
)
);
private readonly deleteTodoState = this.updater((state, todoId: number) => ({
error: undefined,
loading: false,
todos: state.todos.filter((todo) => todo.id !== todoId),
}));
The last step is to build our component:
@Component({
standalone: true,
imports: [NgIf, NgFor, MatProgressSpinnerModule, LetModule],
providers: [provideComponentStore(AppStore)],
selector: 'app-root',
template: `
<ng-container *nrxLet="vm$ as vm">
<mat-spinner [diameter]="20" color="blue" *ngIf="vm.loading">
</mat-spinner>
<ng-container *ngIf="vm.error; else noError">
Error has occured: {{ vm.error }}
</ng-container>
<ng-template #noError>
<div *ngFor="let todo of vm.todos">
{{ todo.title }}
<button (click)="update(todo.id)">Update</button>
<button (click)="delete(todo.id)">Delete</button>
</div>
</ng-template>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
private appStore = inject(AppStore)
// only one stream goes into our component
vm$ = this.appStore.vm$;
update(todoId: number) {
this.appStore.updateTodo(todoId);
}
delete(todoId: number) {
this.appStore.deleteTodo(todoId);
}
}
In our template, we start by subscribing to our viewmodel
using the ngrxLet
directive provided by @ngrx/component package. We then handle our loading and error state and create our list of todos.
Our component is very simple and doesn't contain any logic. It just acts as a proxy, passing information to the corresponding service.
Issues:
- Inside the component store, having two separate properties to handle the loading and error state is error-prone as we need to update both in case of an error.
- In our template, the loading and error indicator are global, which means they apply to all items. It would be nicer to have one indicator per item.
To resolve the first issue, we will use a small library called ngrx-callstate-store. The library enhances the provided state to handle the callstate for us.
@Injectable()
export class AppStore
extends CallStateComponentStore<{todos: Todo[]}>
implements OnStateInit, OnStoreInit
{/*...*/}
We can delete everything related to the loading or error state and our effects can be rewritten as follow (Only the update effect is shown, but the fetch and the delete effects can be updated in a similar fashion)
readonly updateTodo = this.effect<number>(
pipe(
tap(() => this.startLoading()), // updater to patch our loading state
switchMap((id) => this.todoService.update(id).pipe(
tapResponse(
// we could use stopLoading()
// but we would still need to update our todoList via an updater
(todo) => this.updateTodos(todo),
(error: unknown) => this.handleError( error ) // handle our UNKNOWN error
)
)
)
)
);
private readonly updateTodos = this.updater((state, todo: Todo) => ({
...state,
callState: 'LOADED',
todos: state.todos.map((t) => (t.id === todo.id ? { ...todo } : t)),
}));
The last issue is to split our component into smaller chunks. Is is always a good practice to isolate your data types into their own components and to have smaller component.
Let's create a TodoItemComponent
:
@Component({
//...
providers: [provideComponentStore(TodoItemStore)],
template: `...`
})
export class TodoItemComponent {
@Input() set todo(todo: Todo) {
this.todoItemStore.patchState({ todo });
}
private todoItemStore = inject(TodoItemStore);
vm$ = this.todoItemStore.vm$;
update(todoId: number) {
this.todoItemStore.updateTodo(todoId);
}
delete(todoId: number) {
this.todoItemStore.deleteTodo(todoId);
}
}
- We provide a store that is tied to this component. This means that each todo items will have its own instance of the store. This is necessary in order to create a loading or error state for each item.
- We create a setter on the
@Input
property to patch our state. The entire logic is done inside our store. The component is only a proxy between the template and the store. - The same is true for
update
anddelete
methods, which we redirect to our store.
@Injectable()
export class TodoItemStore extends CallStateComponentStore<{ todo: Todo }> {
private todoService = inject(TodoService);
private todosStore = inject(TodosStore);
private readonly todo$ = this.select((state) => state.todo);
readonly vm$ = this.select(
{
todo: this.todo$,
loading: this.isLoading$,
error: this.error$,
},
{ debounce: true }
);
readonly updateTodo = this.effect<number>(
pipe(
tap(() => this.startLoading()),
switchMap((id) => this.todoService.update(id).pipe(
tapResponse(
(todo) => {
this.stopLoading();
this.todosStore.updateTodo(todo);
},
(error: unknown) => this.handleError(error)
)
)
)
)
);
readonly deleteTodo = this.effect<number>(
pipe(
tap(() => this.startLoading()),
switchMap((id) => this.todoService.delete(id).pipe(
tapResponse(
() => this.todosStore.deleteTodoState(id),
(error: unknown) => this.handleError(error)
)
)
)
)
);
}
- The parent store
TodoStore
is injected insideTodoItemStore
. The parent store will have the same instance among all its children. We need to get the store instance to update the Todo list.TodoItemStore
will mostly handle the loading and error state. - The
updateTodo
anddeleteTodo
methods set the current callState toLOADING
, then make a HTTP request and update the parent state on success or update the current callstate with an error otherwise.
That's it; we have build a nice todo app. We could add a small form to add new todos to the list, but that is beyond the scope of this challenge. You can find the final code in the form of a Pull Request here. (If you want to get it up and running, you can clone the project, check out the solution branch and run nx serve crud
).
I hope you enjoyed this fifth challenge and learned some new Angular techniques.
If you found this article useful, please consider supporting my work by giving it some likes ❤️❤️ to help it reach a wider audience. Don't forget to share it with your teammates who might also find it useful. Your support would be greatly appreciated.
👉 Other challenges are waiting for you at Angular challenges. Come and try them. I'll be happy to review you!
Follow me on Twitter or Github to read more about upcoming Challenges! Don't hesitate to ping me if you have more questions.
Posted on December 26, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 29, 2024