npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, πŸ‘‹, I’m Ryan HefnerΒ  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you πŸ™

Β© 2024 – Pkg Stats / Ryan Hefner

@ultimate/lite-store

v0.0.2

Published

<h1 align="center"> πŸ”’ @ultimate/lite-store </h1> <h4 align="center"> <img width="25" valign="middle" src="https://ultimatecourses.com/static/icons/angular.svg"> 1KB - Reactive Stores with entities and frozen state, without the headache </h4>

Downloads

183

Readme



  • βœ… Services are individual Stores
  • βœ… Entity Creation and Synchronized ids
  • βœ… Fully Type Safe and Unit Tested
  • βœ… State Selection via select()
  • βœ… Complex selectors via createSelector()
  • βœ… Partial or full state updates via setState()
  • βœ… Immutable Objects by default using Object.freeze()
  • βœ… Route guards seamless integration

View the Example App on StackBlitz

Introduction

The goal of the project is to make a Service more powerful. Simply extend the Store and inherit the functionality.

Whilst many developers use NGRX Store, many prefer a 'simpler' pattern using just Services, but this comes with the overhead of Observable management, state selection, entities, generic types, immutable operations and frozen state - which can get a bit messy quickly.

@ultimate/lite-store makes reactive services simple by hiding underlying Observable implementation, entity creation and management, selectors and state access - so you can manage state without the headaches, leaving state services super lean. It is fully typed to support your data structures.

You get automatic "frozen state" out-of-the-box via Object.freeze (turn it off if you want), simple state selectors via select() and createSelector(), and automatic entity support with synchronized ids.

It follows a similar thought pattern to Redux and NGRX Store, however is implemented fully inside a single service.

Installation

Install via npm i @ultimate/lite-store


Here's a quick look at the API to compare a typical reactive store you'd write yourself, versus using @ultimate/lite-store.

❌ Using your own BehaviorSubject:

@Injectable()
export class TodoService {
  private _state: BehaviorSubject<TodoState>;

  constructor() {
    this._state = new BehaviorSubject<TodoState>(initialState);
  }

  get state(): T {
    return this._state.getValue();
  }

  get state$(): Observable<T> {
    return this._state.asObservable()
  }

  get todos$() {
    return this.state$.pipe(
      map(state => state.todos),
      distinctUntilChanged()
    );
  }

  addTodo(todo) {
    const todos = [...this._state.todos, todo];

    this._state.next({
      ...this._state,
      todos
    });
  }
}

βœ… Using @ultimate/lite-store:

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState);
  }

  get todos$(): Observable<Todo> {
    return this.select((state) => state.todos);
  }

  addTodo(todo) {
    this.setState((state) => ({
      todos: [...state.todos, todos]
    });
  }
}

Behind the scenes @ultimate/lite-store uses a BehaviorSubject to manage your state, and automatically spreads in your existing state (alleviating a bit of extra work) so you just merge your changes.

It ships with a simple select() method that supports a string or map function which returns an Observable.

Documentation

✨ Store Instance

Import the Store abstract class and extend your service with it, calling super to pass in initialState and any options:

import { Store } from '@ultimate/lite-store';

export class TodoService extends Store<TodoState> {
  constructor() {
    super(<state>, <options>);
  }
}

Options are freeze and entityId, both optional.

type StoreOptions<T> = {
  freeze?: boolean;
  entityId?: EntityId<T>;
};
  • freeze - defaults true and deep freezes your state recursively using Object.freeze
  • entityId - defaults 'id', use to specify a custom entity property for flattened state

✨ Frozen State

State is frozen automatically using a recursive deep freeze function internally.

You don't need to enable it, but you can disable it per store like this:

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState, { freeze: false }); // disable Object.freeze recursion
  }
}

✨ Select State

Use the select() method to get state from your store.

It returns an Observable which internally uses distinctUntilChanged():

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  get todos$(): Observable<Todo> {
    return this.select(state => state.todos);
  }

  //...
}

You can also use a string but a map function (as above) is recommended:

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  get todos$(): Observable<Todo> {
    return this.select('todos');
  }

  //...
}

✨ Set State

Easily set state and merge existing state by calling setState():

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  addTodo(todo) {
    // update as many properties as you like
    this.setState((state) => ({
      todos: [...state.todos, todos]
    });
  }
}

All other state that exists is automatically merged, consider setState a partial state updater for just changes.

That means you don't need to do this:

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  addTodo(todo) {
    this.setState((state) => ({
      ...state, // ❌ not needed, handled for you
      todos: [...state.todos, todos]
    });
  }
}

The addTodo method acts as your Action, and the object returned via setState acts as your pure function Reducer.

πŸ’₯ View the state unit tests for more in-depth usage.

Each setState() call internally recomposes state and sets it as frozen each time via Object.freeze().

The setState call also returns the new composed state, useful for debugging and logging:

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  addTodo(todo) {
    // setState returns the new state, useful for debugging
    const newState = this.setState((state) => ({
      todos: [...state.todos, todos]
    });

    console.log(newState); // { todos: [...] }
  }
}

You can also access the static state snapshot any time:

const initialState = { todos: [] };

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState);
  }

  getCurrentState() {
    // static access of store state
    console.log(this.state); // { todos: [] }
  }
}

✨ Create Selectors

Selectors work the same as in NGRX Store and other libraries, so there's not much new to learn.

They return a slice of state and can be composed together, returning a projector function.

Here's an example of a selector that returns all completed items:

interface TodoState {
  todos: Todo[];
}

const initialState: TodoState = {
  todos: []
};

const getTodos = (state: TodoState) => state.todos;
const getCompletedTodos = (state: TodoState['todos']) => state.filter(todo => todo.completed);

@Injectable()
export class TodoService extends Store<TodoState> {
  get completedTodos() {
    return this.createSelector(
      getTodos,
      getCompletedTodos
    );
  }

  get todos$(): Observable<Todo> {
    return this.select(this.completedTodos);
  }

  constructor() {
    super(initialState);
  }
}

πŸ’₯ View the selector unit tests for more in-depth usage.

✨ Entity and Ids

First-class, automatic support for entities creation and synchronization of ids is out-of-the-box alongside StoreEntity:

interface StoreEntity<E> {
  ids: string[];
  entities: { [id: string]: E };
}

Import and extend the StoreEntity<T> generic type:

import { Store, StoreEntity } from '@ultimate/lite-store';

interface Todo { title: string; id: string; completed: boolean; }

// automatically inherits `entities` and `ids` from StoreEntity<T>
interface TodoState extends StoreEntity<Todo> {
  // add any other state properties...
}

const initialState: TodoState = {
  ids: [], // string[]
  entities: {} // { [id: string]: Todo }
};

Extending StoreEntity<T> automatically adds ids and entities to your state interface, just both keys to your data structure.

Then, get your data and simply call this.toEntities on your data structure and pass it into setState.

πŸ’Ž When passing an entities property, the setState method automatically generates new ids for you. When you update or delete an entity, the ids are recalculated as well.

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState);
  }

  loadTodos() {
    const todos = [
      { id: 'x7Jk90', title: 'Eat Pizza', completed: false },
      { id: 'fg118k', title: 'Get To Work', completed: false },
    ];

    /*
      {
        'x7Jk90': {
          id: 'x7Jk90', title: 'Eat Pizza', completed: false
        },
        'fg118k': {
          id: 'fg118k', title: 'Get To Work', completed: false
        }
      }
    */
    const entities = this.toEntities(todos);

    this.setState((state) => ({ entities }));

    /*
    {
      ids: ['x7Jk90', 'fg118k'],
      entities: {
        'x7Jk90': {
          id: 'x7Jk90', title: 'Eat Pizza', completed: false
        },
        'fg118k': {
          id: 'fg118k', title: 'Get To Work', completed: false
        }
      }
    }
    */
    console.log(this.state);
  }
}

You would then create a selector to fetch the ids for rendering each entity:

const getTodos = (state: TodoState) => state.ids.map(id => state.entities[id]));

@Injectable()
export class TodoService extends Store<TodoState> {
  get todos$(): Observable<Todo> {
    return this.select(getTodos);
  }

  constructor() {
    super(initialState);
  }

  loadTodos() {
    const todos = [
      { id: 'x7Jk90', title: 'Eat Pizza', completed: false },
      { id: 'fg118k', title: 'Get To Work', completed: false },
    ];

    const entities = this.toEntities(todos);

    this.setState((state) => ({
      entities
    }));
  }
}

To create/update an entity can be done like this, and removed via a spread operator trick:

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState);
  }

  createTodo(todo: Todo) {
    this.setState(({ entities }) => ({
      entities: {
        ...entities, // merge existing `entities`
        [todo.id]: todo // add new one
      }
    }));
  }

  removeTodo(id: string) {
    this.setState((state) => {
      // remove via id, spread rest of the entities
      const { [id]: removed, ...entities } = state.entities;

      return { entities };
    });
  }
}

If your data structure does not use id, for example uid, then you can specify a custom property override in the constructor for use when indexing entities:

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState, { entityId: 'uid' });
  }
}

This is also fully type safe and behind-the-scenes infers the correct types for you.

πŸ’₯ View the entity unit tests for more in-depth usage.

✨ Route Guards and Preloading

It's common practice to keep some loading/loaded indicator in your state tree.

Here's how to easily do this with your service and selectors - then we'll look at route guards:

interface TodoState {
  todos: Todo[];
  loaded: boolean;
}

const initialState: TodoState = {
  todos: [],
  loaded: false
};

const getTodos = (state: TodoState) => state.todos;
const getLoadedTodos = (state: TodoState) => state.loaded;

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState);
  }

  get todos$(): Observable<Todo[]> {
    return this.select(getTodos);
  }

  get loaded$(): Observable<boolean> {
    return this.select(getLoadedTodos);
  }

  async loadTodos() {
    try {
      const todos = await firstValueFrom(
        this.http.get<Todo[]>(
          `https://jsonplaceholder.typicode.com/users/1/todos`
        )
      );

      this.setState(() => ({
        todos,
        loaded: true,
      }));
    } catch (e) {
      console.log(e);
    }
  }
}

Here's an example using a route guard to ensure your data has loaded before navigating to the component.

Simply inject the service and pipe() off the loaded$ Observable and check the loaded property:

import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { Observable, tap, of, filter, take, catchError } from 'rxjs';

import { TodoService } from '../services/todo.service';

@Injectable()
export class TodoGuard implements CanActivate {
  constructor(private todoService: TodoService) {}

  canActivate(): Observable<boolean> {
    return this.todoService.loaded$.pipe(
      tap((loaded) => loaded || this.todoService.loadTodos()),
      filter((loaded) => loaded),
      take(1),
      catchError(() => of(false))
    );
  }
}

✨ Destroy

You can kill all subscribers to your store by invoking the destroy() method at any time:

@Injectable()
export class TodoService extends Store<TodoState> {
  constructor() {
    super(initialState);

    this.destroy(); // kills all subscribers
  }
}

Internally this calls Observable.complete(), thus ending any further notifications to subscribers.


I'd recommend checking out the full unit tests to see further use cases.