loading...
Cover image for Introducing One To Many Relationship in Angular & Akita

Introducing One To Many Relationship in Angular & Akita

arielgueta profile image Ariel Gueta ・6 min read

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 commentsids. 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.

Posted on Mar 1 by:

Discussion

markdown guide