In this article, I will show you how to create a blog with Angular and Akita. Along the way, we will learn about two strategies we can use to manage One-to-many relationships with Akita.
Our demo application will feature the main page where we show the list of articles and an article page where we show the complete article with its comments. We will add the functionality to add, edit and remove a comment. So our One-to-many relationship, in this case, is "an article has many comments" or "a comment belongs to an article".
Let's see how we tackle this, but first, let's see the response shape we get from the server:
[{
id: string;
title: string;
content: string;
comments: [{
id: string;
text: string;
}]
}]
We get an array of articles, where each article holds its comments in a comments
property.
Strategy One - Unnormalized Data
We will start by looking at the unnormalized data version. This means that we will use the server response as is without modifying it. We will use one store, i.e., an ArticleStore
that will store the article and its comments. Let's see it in action.
First, we need to add Akita to our project:
ng add @datorama/akita
The above command adds Akita, Akita's dev-tools, and Akita's schematics into our project. The next step is to create a store. We need to maintain a collection of articles, so we scaffold a new entity feature:
ng g af articles
This command generates a articles store, a articles query, a articles service, and an article model for us:
// article.model
import { ID } from '@datorama/akita';
export interface ArticleComment {
id: ID;
text: string;
}
export interface Article {
id: ID;
title: string;
content: string;
comments: ArticleComment[];
}
// articles.store
export interface ArticlesState extends EntityState<Article> {}
@Injectable({ providedIn: 'root' })
@StoreConfig({ name: 'articles' })
export class ArticlesStore extends EntityStore<ArticlesState, Article> {
constructor() { super() }
}
// articles.query
@Injectable({ providedIn: 'root' })
export class ArticlesQuery extends QueryEntity<ArticlesState, Article> {
constructor(protected store: ArticlesStore) {
super(store);
}
}
Now, let's define our routes:
const routes: Routes = [
{
component: HomePageComponent,
path: '',
pathMatch: 'full'
},
{
component: ArticlePageComponent,
path: ':id'
}
];
Let's create the HomePageComponent
:
@Component({
templateUrl: './homepage.component.html',
styleUrls: ['./homepage.component.css']
})
export class HomepageComponent implements OnInit {
articles$ = this.articlesQuery.selectAll();
loading$ = this.articlesQuery.selectLoading();
constructor(private articlesService: ArticlesService,
private articlesQuery: ArticlesQuery) {
}
ngOnInit() {
!this.articlesQuery.getHasCache() && this.articlesService.getAll();
}
}
We use the built-in Akita query selectors. The selectAll
selector which reactively get the articles from the store and the selectLoading
selector as an indication of whether we need to show a spinner.
In the ngOnInit
hook, we call the service's getAll
method that fetches the articles from the server and adds them to the store.
@Injectable({ providedIn: 'root' })
export class ArticlesService {
constructor(private store: ArticlesStore,
private http: HttpClient) {
}
async getAll() {
const response = await this.http.get('url').toPromise();
this.store.set(response.data);
}
}
In our case, we want to fetch them only once, so we use the built-in getHasCache()
to check whether we have data in our store. The internal store's cache property value is automatically changed to true
when we call the store's set
method. Now, we can build the template:
<section class="container">
<h1>Blog</h1>
<h3 *ngIf="loading$ | async; else content">Loading...</h3>
<ng-template #content>
<app-article-preview *ngFor="let article of articles$ | async;"
[article]="article"></app-article-preview>
</ng-template>
</section>
Let's move on to the article page component.
@Component({
templateUrl: './article-page.component.html',
styleUrls: ['./article-page.component.css']
})
export class ArticlePageComponent implements OnInit {
article$: Observable<Article>;
articleId: string;
selectedComment: ArticleComment = {} as ArticleComment;
constructor(private route: ActivatedRoute,
private articlesService: ArticlesService,
private articlesQuery: ArticlesQuery) {
}
ngOnInit() {
this.articleId = this.route.snapshot.params.id;
this.article$ = this.articlesQuery.selectEntity(this.articleId);
}
async addComment(input: HTMLTextAreaElement) {
await this.articlesService.addComment(this.articleId, input.value);
input.value = '';
}
async editComment() {
await this.articlesService.editComment(this.articleId, this.selectedComment);
this.selectedComment = {} as ArticleComment;
}
deleteComment(id: string) {
this.articlesService.deleteComment(this.articleId, id);
}
selectComment(comment: ArticleComment) {
this.selectedComment = { ...comment };
}
trackByFn(index, comment) {
return comment.id;
}
}
First, we obtain the current article id from the ActivatedRoute
provider snapshot property. Then, we use it to reactively select the article from the store by using the selectEntity
selector. We create three methods for adding, updating and deleting a comment. Let's see the template:
<div *ngIf="article$ | async as article">
<h1>{{ article.title }}</h1>
<p>{{ article.content }}</p>
<h3>Comments</h3>
<div *ngFor="let comment of article.comments; trackBy: trackByFn"
(click)="selectComment(comment)">
{{ comment.text }}
<button (click)="deleteComment(comment.id)">Delete</button>
</div>
<h5>New Comment</h5>
<div>
<textarea #comment></textarea>
<button type="submit" (click)="addComment(comment)">Add</button>
</div>
<h5>Edit Comment</h5>
<div>
<textarea [(ngModel)]="selectedComment.text"></textarea>
<button type="submit" (click)="editComment()">Edit</button>
</div>
</div>
And let's finish with the complete service implementation.
@Injectable({ providedIn: 'root' })
export class ArticlesService {
constructor(private store: ArticlesStore,
private http: HttpClient) {
}
async getAll() {
const response = await this.http.get('url').toPromise();
this.store.set(response.data);
}
async addComment(articleId: string, text: string) {
const commentId = await this.http.post(...).toPromise();
const comment: ArticleComment = {
id: commentId,
text
};
this.store.update(articleId, article => ({
comments: arrayAdd(article.comments, comment)
}));
}
async editComment(articleId: string, { id, text }: ArticleComment) {
await this.http.put(...).toPromise();
this.store.update(articleId, article => ({
comments: arrayUpdate(article.comments, id, { text })
}));
}
async deleteComment(articleId: string, commentId: string) {
await this.http.delete(...).toPromise();
this.store.update(articleId, article => ({
comments: arrayRemove(article.comments, commentId)
}));
}
}
In each CRUD method, we first update the server, and only when the operation succeeded, we use Akita's built-in array utils to update the relevant comment.
Now, let's examine the alternative strategy.
Strategy Two - Data Normalization
This strategy requires to normalize the data we get from the server. The idea is to create two stores. CommentsStore
which is responsible for storing the entire comments. ArticlesStore
which is responsible for storing the articles where each article has a comments
array property which contains the ids of the associated comments.
ng g af articles
ng g af comments
Let's see the models.
// article.model
export interface Article {
id: ID;
title: string;
content: string;
comments: (Comment | ID)[];
}
// commment.model
export interface Comment {
id: ID;
text: string;
}
Now, let's modify the ArticleService
getAll
method.
@Injectable({ providedIn: 'root' })
export class ArticlesService {
constructor(private articlesStore: ArticlesStore,
private commentsService: CommentsService,
private commentsStore: CommentsStore,
private http: HttpClient) {
}
async getAll() {
const response = await this.http.get('url').toPromise();
const allComments = [];
const articles = response.data.map(currentArticle => {
const { comments, ...article } = currentArticle;
article.comments = [];
for(const comment of comments) {
allComments.push(comment);
article.comments.push(comment.id);
}
return article;
});
this.commentsStore.set(allComments);
this.articlesStore.set(articles);
}
}
We create a new articles array where we replace the comment
object from each article with the comment id. Next, we create the allComments
array, which holds the entire comments. Finally, we add both of them to the corresponding store.
Now, lets' see what we need to change in the article page. As we need to show the article and its comments, we need to create a derived query which joins an article with its comments. Let's create it.
@Injectable({ providedIn: 'root' })
export class ArticlesQuery extends QueryEntity<ArticlesState, Article> {
constructor(protected store: ArticlesStore, private commentsQuery: CommentsQuery) {
super(store);
}
selectWithComments(articleId: string) {
return combineLatest(
this.selectEntity(articleId),
this.commentsQuery.selectAll({ asObject: true })
).pipe(map(([article, allComments]) => ({
...article,
comments: article.comments.map(id => allComments[id])
})));
}
}
We create the selectWithComments
selector which takes the articleId
, and creates a join between the article and the comments, and returns a mapped version with the comments based on the comments
ids. Now, we can use it in the component:
export class ArticlePageComponent implements OnInit {
article$: Observable<Article>;
constructor(private route: ActivatedRoute,
private articlesService: ArticlesService,
private articlesQuery: ArticlesQuery) {
}
ngOnInit() {
this.articleId = this.route.snapshot.params.id;
this.article$ = this.articlesQuery.selectWithComments(this.articleId);
}
}
Let's finish seeing the changes in the ArticlesService
:
@Injectable({ providedIn: 'root' })
export class ArticlesService {
constructor(private articlesStore: ArticlesStore,
private commentsService: CommentsService,
private commentsStore: CommentsStore,
private http: HttpClient) {
}
async getAll() {}
async addComment(articleId: string, text: string) {
const commentId = await this.commentsService.add(articleId, text);
this.articlesStore.update(articleId, article => ({
comments: arrayAdd(article.comments, commentId)
}));
}
async editComment(comment: Comment) {
this.commentsService.edit(comment);
}
async deleteComment(articleId: string, commentId: string) {
await this.commentsService.delete(commentId);
this.articlesStore.update(articleId, article => ({
comments: arrayRemove(article.comments, commentId)
}));
}
}
In this case, when we perform add or remove operations, we need to update both the CommentsStore
and the ArticlesStore
. In the case of an edit, we need to update only the CommentsStore
. Here is the CommentsService
.
@Injectable({ providedIn: 'root' })
export class CommentsService {
constructor(private commentsStore: CommentsStore) {
}
async add(articleId: string, text: string) {
const id = await this.http.post().toPromise();
this.commentsStore.add({
id,
text
});
return id;
}
async delete(id: string) {
await await this.http.delete(...).toPromise();
this.commentsStore.remove(id);
}
async edit(comment: Comment) {
await this.http.put(...).toPromise();
return this.commentsStore.update(comment.id, comment);
}
}
Summary
We learn about two strategies of how we can manage One-to-many relationships with Akita. In most cases, I will go with the first strategy as it is cleaner, shorter, and more straightforward. The second strategy might be useful when you have massive edit operations in your application, and you care about performance.
But remember, premature optimizations are the root of all evil.
Top comments (3)
This is nice, but how do you manage to propagate changes back to the list in the parent entity, e.g. when editing a comment, since you have no observable? Does akita takes care of it automatically when querying?
From datorama.github.io/akita/docs/addi... :
Great tutorial, Ariel, but I am searching for a new scenario. What about in this classes below in Java, please?
public class User implements Serializable, UserDetails {
...
}
public class Account implements Serializable {
...
}
Thanks in advance! :)