I am currently learning Angular and following the Maximilian Schwarzmüller Udemy course.
I have a simple CRUD application to manage recipes with data saved in a database, and I want to be able to delete, create, and update a recipe by sending an HTTP request, and then update the list of recipes accordingly.
Using just RxJS BehaviorSubject and a service, I currently have this implementation to delete a recipe:
export class RecipeDetailComponent implements OnInit {
selectedRecipe: Recipe;
constructor(
private recipeService: RecipeService,
private activatedRoute: ActivatedRoute,
private router: Router
) { }
ngOnInit() {
this.activatedRoute.params.subscribe((params) => {
const id = params.id;
this.recipeService.getRecipeById(id).subscribe((recipe) => {
this.selectedRecipe = recipe;
});
});
}
onDeleteRecipe() {
this.recipeService.deleteRecipe(this.selectedRecipe.id).subscribe({
next: () => {
this.router.navigate(['/recipes']);
this.recipeService.recipesUpdate.next();
},
error: (error) => {
console.error('error deleting recipe : ', error);
}
});
}
}
export class RecipeListComponent implements OnInit {
recipes$: Observable<Recipe[]>;
isLoading = false;
errorMessage: string;
constructor(private recipeService: RecipeService) {}
ngOnInit() {
this.initRecipes();
this.recipeService.recipesUpdate.subscribe(() => this.initRecipes());
}
initRecipes() {
this.isLoading = true;
this.recipes$ = this.recipeService.getRecipes().pipe(
catchError((error) => {
console.error('error retrieving recipes : ', error);
this.errorMessage = `Error retrieving recipes : ${error.error.error}`;
return of([]);
}),
tap({ complete: () => (this.isLoading = false) })
);
}
}
export class RecipeService {
API_URL =
'XXX';
private recipes: Recipe[] = [];
recipesUpdate: Subject<void> = new Subject<void>();
recipes$ = new BehaviorSubject<Recipe[]>(this.recipes);
constructor(private http: HttpClient) { }
getRecipes() {
return this.http.get<Recipe[]>(`${this.API_URL}/recipes.json`)
}
getRecipeById(id: string) {
return this.http.get<Recipe>(`${this.API_URL}/recipes/${id}.json`)
}
addRecipe(recipe: Recipe) {
return this.http
.post(`${this.API_URL}/recipes.json`, recipe)
.subscribe((response) => {
this.recipesUpdate.next();
});
}
updateRecipe(recipe: Recipe) {
return this.http
.put(`${this.API_URL}/recipes/${recipe.id}.json`, recipe)
.subscribe((response) => {
this.recipesUpdate.next();
});
}
deleteRecipe(id: string) {
return this.http.delete(`${this.API_URL}/recipes/${id}.json`);
}
}
I'm not sure if this is the best way to do it, particularly the way I am updating the list of recipes in the RecipeListComponent using an empty Subject and subscribing to it in the ngOnInit method.
I have read many comments about NgRx and how it is often considered overkill for simple applications (https://blog.angular-university.io/angular-2-redux-ngrx-rxjs/), but I am not sure how to do it without using it.
Furthermore, I don't like the fact that I have to "reload" the recipe list and show the loader after deleting, creating, or updating a recipe. I used React Query with React for this purpose. Is there a way to achieve the same with Angular?
BONUS QUESTION: Regarding NgRx, in my Angular course, I am almost at the part about NgRx, but I am unsure whether I should follow it or not. Do you think it is worth learning?
First of all wanted to commend you on great first question documentation. I also was starting with Maximilian Schwarzmüller.
Using services for state management is perfectly fine approach. And overall what you are doing there is fine. I would recommend to investigate imperative vs declarative code (Joshua Morony has decent content on the topic). For example, you can display recipes directly in the template using async
pipe avoiding subscription hassle.
What you are describing about loader, this behavior can be mitigated by using optimistic rendering. When user performs CRUD operation you make change on local state simultaneously with remote state, not showing any loader, in case of an error you rollback local state and show user an error.
A detail regarding "recipes$" in RecipeListComponent: when an error occurs in the observable sequence, the catchError operator is triggered, and it handles the error and returns a new observable. In this case, the catchError operator returns an observable of an empty array (of([])). Therefore, the tap operator will not be reached and its side effect, setting isLoading to false, will not occur.
Regarding bonus question: I have started using ngrx 4 month ago, and consider it a great tool. At this point I would argue that if ngrx is overkill than so is angular itself. Once you have enough practice its not harder that services. NGRX forces you to create more transparent and cleaner data flows which can save you a lot of debugging time. Here comes but... but I do believe that ngrx is overkill for someone starting with angular, from learning curve point of view. I would definitely wait until I had solid grasp on Angular itself before adding on more weight.
Hope I didn't miss anything.
Refactor suggestion
After looking your code closer I saw some redundant actions. I would suggest refactoring your code. Here is fetching recipes as an example:
When we call get recipes, first of all we push loading true to inform everyone listening that recipes are being loaded. Then we make api call itself. You may notice take(1) pipe, its purpose is to automatically unsubscribe after first emission. Depending on backend you are using it may be unnecessary, be still its a good practice to prevent memory leaks.
Also we have finalize pipe which will execute when observable completes or errors.
When value is received it is sent through recipes$.
export class RecipeService {
API_URL = 'XXX';
isLoading$ = new BehaviorSubject<boolean>(false);
recipes$ = new BehaviorSubject<Recipe[]>([]);
errorMessage: string;
constructor(private http: HttpClient) { }
getRecipes() {
this.isLoading$.next(true);
this.http.get(`${this.API_URL}/recipes.json`)
.pipe(
take(1),
finalize(() => this.isLoading$.next(false))),
catchError((error) => {
console.error('error retrieving recipes : ', error);
this.errorMessage = `Error retrieving recipes : ${error.error.error}`;
return of([]);
}
).subscribe((recipes)=>{
this.recipes$.next(recipes)
})
}
}
On initialization of our list component we execute getRecipes() to make backend call.
export class RecipeListComponent implements OnInit {
recipes$: Observable<Recipe[]> = this.recipeService.recipes$;
errorMessage: string;
constructor(private recipeService: RecipeService) { }
ngOnInit() {
this.recipeService.getRecipes();
}
}
Template would be:
<app-recipe-item *ngFor="let recipe of recipes$ | async"></app-recipe-item>