Akita Documentation

repository·master·Indexed 25 days ago

https://github.com/salesforce/akita

A reactive state management pattern built on top of RxJS that combines Flux's multiple data stores, Redux's immutable updates, and streaming data to create an Observable Data Stores model for JS applications (Angular, React, Vue, etc.). Note: Akita is no longer maintained; the authors recommend using Elf for new projects.

Tokens
42.8K
Snippets
143
Records
232
Agent score
86%

What's inside Akita

  1. Implement an EntityStore for collections

    master

    To manage a collection of entities (e.g., a list of products), use EntityStore and QueryEntity.

    1. Define an EntityState interface that extends EntityState<T, ID>.
    2. Extend EntityStore<State> for the store.
    3. Extend QueryEntity<State> for the query.
    4. Use methods like .set(), .add(), .update(), and .remove() in your service to manage the collection.
    import { Injectable } from '@angular/core';
    import { EntityState, EntityStore, StoreConfig, QueryEntity, ID } from '@datorama/akita';
    
    // Model
    export interface Product {
      id: number;
      title: string;
      description: string;
      price: number;
    }
    
    // Store
    export interface ProductsState extends EntityState<Product, number> {}
    
    @Injectable({ providedIn: 'root' })
    @StoreConfig({ name: 'products' })
    export class ProductsStore extends EntityStore<ProductsState> {
      constructor() {
        super();
      }
    }
    
    // Query
    @Injectable({ providedIn: 'root' })
    export class ProductsQuery extends QueryEntity<ProductsState> {
      constructor(protected store: ProductsStore) {
        super(store);
      }
    }
    
    // Service
    @Injectable({ providedIn: 'root' })
    export class ProductsService {
      constructor(private productsStore: ProductsStore, private http: HttpClient) {}
    
      get() {
        return this.http.get<Product[]>('https://api.com').pipe(
          tap((entities) => this.productsStore.set(entities))
        );
      }
    
      add(product: Product) {
        this.productsStore.add(product);
      }
    
      update(id: number, product: Partial<Product>) {
        this.productsStore.update(id, product);
      }
    
      remove(id: ID) {
        this.productsStore.remove(id);
      }
    }
  2. Test Angular components by mocking the Query

    master

    When testing Angular components that use Akita, you can use a mocking strategy for the Query class. This involves providing the Query in the TestBed and using spies to control the return values of selector methods (like selectAll()) or overriding observable properties (like selectFilter$). This allows you to test how the component UI reacts to different data states without interacting with the actual Store.

    describe('TodosPageComponent', () => {
      let component: TodosPageComponent;
      let todosQuery: TodosQuery;
      let fixture: ComponentFixture<TodosComponent>;
    
      beforeEach(async(() => {
        TestBed.configureTestingModule({
          providers: [
            TodosService,
            TodosQuery
          ],
          declarations: [TodosComponent]
        }).compileComponents();
      }));
    
      beforeEach(() => {
        fixture = TestBed.createComponent(TodosComponent);
        component = fixture.componentInstance;
        todosQuery = TestBed.get(TodosQuery);
      });
    
      it('should display no todos message', () => {
        todosQuery.selectAll.and.returnValue(of([]));
        fixture.detectChanges();
        const noMessageElement = fixture.debugElement.query(By.css('.no-todos'));
        expect(noMessageElement).not.toBeNull();
      });
    
      it('should display two todos', () => {
        todosQuery.selectAll.and.returnValue(of([createTodo(), createTodo()]));
        fixture.detectChanges();
        const todos = fixture.debugElement.queryAll(By.css('li'));
        expect(todos.length).toEqual(2);
      });
    
      it('should display the initial filter', () => {
        todosQuery.selectFilter$ = of('active');
        fixture.detectChanges();
        const filter = fixture.debugElement.query(By.css('.filter'));
        expect(filter.nativeElement.innerText).toEqual('active');
      });
    });
  3. Persist Pagination State using Metadata

    master

    The PaginatorPlugin exposes a metadata property that allows you to store and retrieve stateful information, such as current filter values or sort orders. This is useful for persisting user preferences when navigating away from and returning to a paginated view.

    • Use this.paginatorRef.metadata.set(key, value) to save state.
    • Use this.paginatorRef.metadata.get(key) to retrieve state.
    const sortByInit = this.paginatorRef.metadata.get('sortBy') || 'name';
    // ... later in the request flow
    this.paginatorRef.metadata.set('sortBy', sortBy);
    this.paginatorRef.metadata.set('perPage', perPage);
  4. Build the Akita documentation website

    master

    To generate static content for the Akita documentation website, run the yarn build command. The output will be located in the build directory and can be hosted on any static content hosting service.

    $ yarn build
  5. Handle request notifications using a global service (Toaster)

    master

    If you use a global notification system (like a Toaster) and do not need to display the state within the component itself, you can handle notifications directly in the service using RxJS operators like tap and catchError.

    // todos.service.ts
    import { throwError } from 'rxjs';
    
    class TodosService {
      constructor(private toaster: Toaster) {}
      
      updateTodo() {
        return this.http.post().pipe(
          tap(() => {
            this.store.updateEntity();
            this.toaster.success(`🦄`);
          }),
          catchError((err) => {
            this.toaster.error(`🤷🏻‍♂️`);
            return throwError(err);  
          })
        )
      }
    }
  6. Manage local component state in Angular using Akita

    master

    To manage state that is local to a specific component instance (rather than a global singleton), provide the Store and Query classes directly in the component's providers array. This ensures each component instance receives its own unique store instance.

    1. Define a Store class extending Store<State>.
    2. Define a Query class extending Query<State> that accepts the store in its constructor.
    3. Add both classes to the @Component providers array.
    // counter.state.ts
    type State = { counter: number };
    
    @Injectable()
    class CounterStore extends Store<State> {
      constructor() {
        super({ counter: 0 }, { name: `Counter-${guid()}` });
      }
    }
    
    @Injectable()
    class CounterQuery extends Query<State> {
      constructor(protected store: CounterStore) { super(store); }
    }
    
    // counter.component.ts
    @Component({
      selector: 'counter',
      template: `{{ counter$ | async }} <button (click)="increment()">Increment</button>`,
      providers: [CounterStore, CounterQuery]
    })
    export class CounterComponent {
      counter$ = this.query.select('counter');
    
      constructor(
        private store: CounterStore,
        private query: CounterQuery
      ) { }
    
      increment() {
        this.store.update(({ counter }) => ({ counter: counter + 1 }));
      }
    }
  7. Use Transactions to optimize multiple store updates

    master

    Transactions prevent multiple dispatches when performing several operations on a store within the same tick. Instead of triggering subscribers for every individual update, Akita ensures a single dispatch occurs only after all actions within the transaction have completed. This is particularly useful when updating multiple stores simultaneously to ensure query selectors receive consistent, up-to-date values from all involved stores.

    You can implement transactions using three different methods:

    1. Decorator: Use @transaction() on a method.
    2. Function: Wrap logic in applyTransaction(() => { ... }).
    3. Operator: Use withTransaction(response => { ... }) within an RxJS pipe.
    import { 
      transaction, 
      applyTransaction, 
      withTransaction 
    } from '@datorama/akita';
    
    // 1. As a decorator
    @transaction()
    update() {
       this.store.update();
       this.store.setLoading(true);
    }
    
    // 2. As a function
    update() {
      applyTransaction(() => {
        this.store.update();
        this.store.setActive(1);
      });
    }
    
    // 3. As an RxJS operator
    update() {
      return http.get().pipe(
        withTransaction(response => {
           this.store.update(response);
           this.store.setActive(1);
        })
      );
    }
  8. Setup StateHistoryPlugin for undo/redo functionality

    master

    The StateHistoryPlugin allows you to track store changes to provide undo and redo capabilities. To use it, instantiate a new StateHistoryPlugin and pass your existing Query instance to the constructor.

    Configuration Options

    • maxAge: The maximum number of changes to store in history (default: 10).
    • watchProperty: A string representing a specific property to watch for changes.
  9. Define selectors in Queries instead of Components

    master

    To keep components clean and ensure selectors are reusable across the application, avoid defining selector logic directly within your components. Instead, define them within your Query class.

    // auth.query.ts
    export class AuthQuery extends Query<AuthState> {
      isLoggedIn$ = this.select(state => !!state.token);
      
      constructor(protected store: AuthStore) {
        super(store);
      }
    }
  10. Manage Subscriptions for API calls

    master

    When performing side effects like HTTP calls that update the store, choose your subscription strategy based on the requirement:

    1. Subscribe in the Component: Use this if the component needs to react to the success or error of the call (e.g., showing a local success message or error toast).
    2. Subscribe in the Service: Use this if the operation is a 'fire and forget' side effect where the component doesn't need to know the immediate outcome of the request.
    // Option 1: Component-level subscription for local UI feedback
    class TodosComponent {
      ngOnInit() {
       this.todoService.get().subscribe({
         next: () => {
           this.success = true;
         },
         error: (err) => {
          this.error = err;
         }
       });
      }
    }
    
    // Option 2: Service-level subscription for background updates
    class TodoService {
      get() {
        return this.http.get<Todo[]>('/api/todos').subscribe(entities => {
          this.todoStore.set(entities);
        });
      }
    }
  11. Use classes as underlying values in Akita stores

    master

    Akita supports using a class instead of a plain object as the underlying value for entities.

    Important Constraints:

    • The class constructor must accept exactly one parameter, which must be a plain object.
    • Using classes prevents storing store snapshots in databases (due to serialization issues).
    • Classes may cause issues with Web Workers and third-party tools like immer that require plain objects.

    When using update(), Akita automatically instantiates a new instance of the class by merging the current entity state with the new parameters provided.

    export class User {
      constructor({ firstName, lastName, token }: Partial<User>) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.token = token;
      }
    
      get name() {
        return `${this.firstName} ${this.lastName}`;
      }
    }
    
    @StoreConfig({ name: 'user' })
    export class UserStore extends EntityStore<UserState> {
      constructor() {
        super();
      }
    }