NgFrituur - Intro to Akita state management
2019-04-27
Akita is a state management pattern, built on top of RxJS, which takes the idea of multiple data stores from Flux and the immutable updates from Redux, along with the concept of streaming data, to create the Observable Data Stores model.
Akita encourages simplicity. It saves you the hassle of creating boilerplate code and gives powerful tools with a moderate learning curve, suitable for both experienced and inexperienced developers alike.
Akita is based on object-oriented design principles instead of functional programming, so developers with OOP experience should feel right at home. Its opinionated structure provides your team with a fixed pattern that cannot be deviated from.- netbasal.com
That last part about object-oriented design really got me interested as I mostly develop applications written in Java.
Akita, what?
- The Store is a single object that contains the store state and serves as the “single source of truth.”
- The only way to change the state is by calling set() or one of the update methods based on it.
- A component should NOT get data from the store directly but instead use a Query.
- Asynchronous logic and update calls should be encapsulated in services and data services.
Demo application
I decided to build a demo application about a subject we are very familiar with here in Belgium, a 'Frituur'. A frituur is a place where people can mainly buy and consume snacks such as fries, croquettes, frikandels and other delicious snacks.
The application shows a list of available snacks and gives the user the option to filter snacks. Here is a small video to quickly show you how it behaves:
Service
The SnacksService is an Injectable that will get called from within a component and will trigger the initial data flow in the application. Both getSnacks() and getCategories() will fetch data from a local server and store it in the Akita entity store. updateSelectedCategory() updates UI related state:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
@Injectable({ providedIn: 'root' }) export class SnacksService { constructor(private http: HttpClient, private snacksStore: SnacksStore, private snacksQuery: SnacksQuery) { } updateSelectedCategory(category: Category) { this.snacksStore.update({ ui: { selectedCategory: category } }); } getData() { const snacksAndCategories$ = forkJoin([ this.getSnacks(), this.getCategories() ] ); return this.snacksQuery.getHasCache() ? EMPTY : snacksAndCategories$; } private getSnacks(): Observable<Snack[]> { return this.call<Snack[]>('snacks').pipe( tap(response => this.snacksStore.set(response)) ); } private getCategories(): Observable<Category[]> { return this.call<Category[]>('categories').pipe( tap(categories => this.snacksStore.update({categories})) ); } private call<T>(url: string): Observable<T> { return this.http.get<T>(`${server}/${url}`); } }
Store
The store is just a simple class extending EntityStore passing in the initial state and the entity type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
export interface SnacksState extends EntityState<Snack> { ui: { selectedCategory: Category; }; categories: Category[]; } const initialState: Partial<SnacksState> = { ui: { selectedCategory: null } }; @Injectable({ providedIn: 'root' }) @StoreConfig({ name: 'snacks' }) export class SnacksStore extends EntityStore<SnacksState, Snack> { constructor() { super(initialState); } }
Query
The query is used by components to get data out of the store. SnacksQuery has 3 visible 'operations'.
- selectCategories$ -> Returns an observable of categories.
- selectedCategory$ -> Returns an observable of the currently selected category.
- selectVisibleSnacks() -> Returns an observable of snacks based on the currently active category.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
@Injectable({ providedIn: 'root' }) export class SnacksQuery extends QueryEntity<SnacksState, Snack> { selectCategories$ = this.select(state => state.categories); selectedCategory$ = this.select(state => state.ui.selectedCategory); constructor(protected store: SnacksStore) { super(store); } static filterBy(category) { return (snack) => { if (category) { return snack.category === category.id; } return true; }; } selectVisibleSnacks(): Observable<Snack[]> { return this.selectedCategory$.pipe(switchMap(category => { return this.selectAll({ filterBy: SnacksQuery.filterBy(category) }); })); } }
Snacks overview - template
Automatically subscribing using the async pipe.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
<app-snacks-filter [categories]="categories$ | async" (categoryChange)="categoryChanged($event)"> </app-snacks-filter> <ng-container *ngIf="!(loading$ | async); else loadingTpl"> <div class="snacks" fxLayout="row wrap" fxLayout.xs="column" fxLayoutGap="20px grid"> <app-snack fxFlex.gt-xs="50" fxFlex.gt-sm="33" *ngFor="let snack of snacks$ | async" [snack]="snack"> </app-snack> </div> </ng-container> <ng-template #loadingTpl> <div class="progress"> <mat-spinner></mat-spinner> </div> </ng-template>
Snacks overview - component
One way data flow is used throughout the application, events as categoryChanged will be propagated to the container.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
@Component({ selector: 'app-snacks', templateUrl: './snacks.component.html', styleUrls: ['./snacks.component.scss'] }) export class SnacksComponent implements OnInit { loading$: Observable<boolean>; snacks$: Observable<fromState.Snack[]>; categories$: Observable<fromState.Category[]>; constructor(private snacksQuery: fromState.SnacksQuery, private snacksService: fromState.SnacksService) { } ngOnInit() { this.loading$ = this.snacksQuery.selectLoading(); this.snacks$ = this.snacksQuery.selectVisibleSnacks(); this.categories$ = this.snacksQuery.selectCategories$; this.startDataFlow(); } private startDataFlow(): void { this.snacksService.getData().subscribe(); } categoryChanged(category: fromState.Category): void { this.snacksService.updateSelectedCategory(category); } }
You can find the full code over here. If you have any questions, do not hesitate to contact me or leave a comment below.