Coder Social home page Coder Social logo

dmorosinotto / ng16-signal-store-playground Goto Github PK

View Code? Open in Web Editor NEW

This project forked from markostanimirovic/ngrx-signal-store-playground

0.0 0.0 0.0 408 KB

Angular16 - experiments with Signals + NgRxSignalStore (Playground)

Home Page: https://github.com/ngrx/platform/discussions/3796

TypeScript 98.59% HTML 1.26% SCSS 0.15%

ng16-signal-store-playground's Introduction

#NG16 EXPERIMENTS TO USE SIGNALS

  • How to use Signal with OLD NgRxStore RFC -> consume Observables with toSignal() without | async or *ngrxLet on tamplate -> interop store.selectSignal(selector)
  • NEW WAY Store based on Signal this NgRxSignalStore RFC

NgRx SignalStore

Main goals:

  • Providing unidirectional and predictable data flow with signals.
  • Keeping a declarative approach instead of "imperative reactivity" that is possible with signals.
  • Separating side effects from the state to avoid unpredictable data flows.

Key principles:

  • Simple and intuitive
  • Declarative
  • Composable
  • Tree-shakeable
  • Strongly typed

Package name suggestions:

  • @ngrx/signals
  • @ngrx/signal-store
  • @ngrx/state

Contents

createSignalStore

The createSignalStore function acts as a pipe that accepts a sequence of store features. By using various store features, we can add state slices, computed state, updaters, effects, hooks, and DI configuration to the signal store.

Store Features

  • withState - accepts a dictionary of state slices, and converts each slice into a signal.
  • withComputed - accepts the previous state slices and computed properties as factory argument. Returns a dictionary of computed properties.
import { createSignalStore, withState, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';

type UsersState = {
  users: User[];
  query: string;
};

const UsersStore = createSignalStore(
  withState<UsersState>({ users: [], query: '' }),
  // we can access previously defined state slices via factory argument
  withComputed(({ users, query }) => ({
    filteredUsers: computed(() =>
      // 'users' and 'query' slices are signals
      users().filter(({ name }) => name.includes(query()))
    ),
  }))
);

@Component({
  providers: [UsersStore],
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
  // available properties:
  // - state slices:
  //     usersStore.users: Signal<User[]>
  //     usersStore.query: Signal<string>
  // - computed:
  //     usersStore.filteredUsers: Signal<User[]>
}

DI Config

In the previous example we saw default behavior - createSignalStore returns a token that can be further provided and injected where needed. However, we can also provide a signal store at the root level by using { providedIn: 'root' } config:

import { createSignalStore, withState } from '@ngrx/signals';

type UsersState = { users: User[]; query: string };

const UsersStore = createSignalStore(
  { providedIn: 'root' },
  withState<UsersState>({ users: [], query: '' })
);

@Component({
  /* ... */
})
export class UsersComponent {
  // all consumers will inject the same instance of users store
  readonly usersStore = inject(UsersStore);
}

update Function

The update function is used to update the signal store state. It accepts a sequence of partial state objects or updater functions that partially updates the state. This provides the ability to define reusable updater functions that can be used in any signal store.

Examples:

type CallState = 'init' | 'loading' | 'loaded';
type UsersState = { users: User[]; callState: CallState };

const UsersStore = createSignalStore(
  withState<UsersState>({ users: [], callState: 'init' })
);
const usersStore = inject(UsersStore);

// passing partial state object (replace all array):
usersStore.update({ users: ['u1', 'u2'] });

// passing updater function (add item to array):
usersStore.update((state) => ({
  users: [...state.users, 'u3'],
  callState: 'loaded',
}));

// passing a sequence of partial state objects and/or updater functions:
usersStore.update((state) => ({ users: [...state.users, 'u4'] }), {
  callState: 'loaded',
});

// We can also define reusable and tree-shakeable updater functions
// that can be used in any signal store (like factories):
function removeInactiveUsers(): (state: { users: User[] }) => { users: User[]; } 
{
  return (state) => ({ users: state.users.filter((user) => user.isActive) });
}

function setLoaded(): { callState: CallState } {
  return { callState: 'loaded' };
}

// using updater functions:
usersStore.update(removeInactiveUsers(), setLoaded());

  • withUpdaters - provides the ability to add updaters to the signal store. Its factory accepts state slices, computed properties, previously defined updaters, and update function as an input argument.
  • withEffects - provides the ability to add effects to the signal store. Its factory accepts state slices, computed properties, updaters, previously defined effects, and update function as an input argument.
  • withHooks - provides the ability to add custom logic on signal store init and/or destroy. Hook factories also accept state slices, computed properties, updaters, and effects.
import {
  createSignalStore,
  withState,
  withComputed,
  withUpdaters,
  withEffects,
  withHooks,
  rxEffect,
} from '@ngrx/signals';
import { computed } from '@angular/core';

type UsersState = {
  users: User[];
  query: string;
};

const UsersStore = createSignalStore(
  withState<UsersState>({ users: [], query: '' }),
  withComputed(({ users, query }) => ({
    filteredUsers: computed(() =>
      users().filter(({ name }) => name.includes(query()))
    ),
  })),
  // we can access the 'update' function via updaters/effects
  // factory argument
  withUpdaters(({ update, users }) => ({
    addUsers: (newUsers: User[]) => {
      update((state) => ({ users: [...state.users, ...newUsers] }));
      // or:
      // update({ users: [...users(), ...newUsers] })
    },
  })),
  withEffects(({ addUsers }) => {
    const usersService = inject(UsersService);
    // read more about 'rxEffect' in the section below
    const loadUsers = rxEffect<void>(
      pipe(
        exhaustMap(() => usersService.getAll()),
        tap((users) => addUsers(users))
      )
    );

    return { loadUsers };
  }),
  withHooks({
    onInit: ({ loadUsers }) => loadUsers(),
    onDestroy: ({ filteredUsers }) =>
      console.log('users on destroy:', filteredUsers()),
  })
);

@Component({
  providers: [UsersStore],
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
  // available properties and methods:
  // - usersStore.update method
  // - usersStore.users: Signal<User[]>
  // - usersStore.query: Signal<string>
  // - usersStore.filteredUsers: Signal<User[]>
  // - usersStore.addUsers: (users: User[]) => void
  // - usersStore.loadUsers: () => Subscription
}

However, it's not mandatory to use rxEffect and RxJS APIs when defining the SignalStore side effects. We can also create the effect in the following way:

const UsersStore = createSignalStore(
  withState<UsersState>({ users: [], loading: false }),
  withEffects(({ update }) => ({
    // creating side effect by using async/await approach
    async loadUsers() {
      update({ loading: true });
      const users = await fetchUsers();
      update({ users, loading: false });
    },
  })),
  withHooks({
    onInit: ({ loadUsers }) => loadUsers(),
  })
);

function fetchUsers(): Promise<User[]> {
  return fetch('/users').then((res) => res.json());
}

rxEffect

The rxEffect function is a similar API to ComponentStore.effect. It provides the ability to manage asynchronous side effects by using RxJS. It returns a function that accepts a static value, signal, or observable as an input argument.

The rxEffect function can be used with createSignalStore as we saw above or completely independent. When used within the component injection context, it will clean up subscription on destroy.

The rxEffect function can be part of the @ngrx/signals / @ngrx/state package or @ngrx/signals/rxjs-interop / @ngrx/state/rxjs subpackage.

Examples:

import { rxEffect } from '@ngrx/signals';
import { signal } from '@angular/core';

@Component({
  /* ... */
})
export class UsersComponent implements OnInit {
  private readonly usersService = inject(UsersService);

  readonly users = signal<User[]>([]);
  readonly loading = signal(false);
  readonly query = signal('');

  readonly loadUsersByQuery = rxEffect<string>(
    pipe(
      tap(() => this.loading.set(true)),
      switchMap((query) => this.usersService.getByQuery(query)),
      tap((users) => {
        this.users.set(users);
        this.loading.set(false);
      })
    )
  );

  ngOnInit(): void {
    // The effect will be executed every time when query signal changes.
    // It will clean up supscription when 'UsersComponent' is destroyed.
    this.loadUsersByQuery(this.query);
    // If it's called with static value (loadUsers('ngrx')), the effect
    // will be executed only once.
    // If it's called with observable (loadUsers(query$)), the effect
    // will be executed every time when 'query$' observable emits a new
    // value.
  }
}

Custom Store Features

Every store feature returns an object that contains following properties:

type SignalStoreFeature = {
  state: Record<string, Signal<unknown>>;
  computed: Record<string, Signal<unknown>>;
  updaters: Record<string, (...args: unknown[]) => void>;
  effects: Record<string, (...args: unknown[]) => unknown>;
  hooks: {
    onInit: () => void;
    onDestroy: () => void;
  };
};

For example, we can define withCallState feature in the following way:

There can also be a helper function (createSignalStoreFeature) to create custom features.

import { signal, computed } from '@angular/core';

type CallState = 'init' | 'loading' | 'loaded' | Error;

function withCallState(): () => {
  state: { callState: Signal<CallState> };
  computed: {
    loading: Signal<boolean>;
    loaded: Signal<boolean>;
    error: Signal<unknown>;
  };
} {
  return () => {
    const callState = signal<CallState>('init');

    return {
      state: { callState },
      computed: {
        loading: computed(() => callState() === 'loading'),
        loaded: computed(() => callState() === 'loaded'),
        error: computed(() =>
          typeof callState() === 'object' ? callState()?.error : null
        ),
      },
    };
  };
}

This feature can be further used in any signal store that needs call state as follows:

const UsersStore = createSignalStore(
  withState<{ users: string[] }>({ users: [] }),
  withCallState()
);

const usersStore = inject(UsersStore);
// usersStore contains following properties:
// - usersStore.users: Signal<string[]>
// - usersStore.callState: Signal<CallState>
// - usersStore.loading: Signal<boolean>
// - usersStore.loaded: Signal<boolean>
// - usersStore.error: Signal<unknown>

// updating:

usersStore.update({ callState: 'loading' });
// or by using reusable updater function:
usersStore.update(setLoaded());

function setLoaded(): { callState: 'loaded' } {
  return { callState: 'loaded' };
}

Entity Management

This package should provide the following APIs:

  • withEntities feature that will add entityMap and ids as state, and entities (entity list) as computed property
  • tree-shakeable updater functions: setOne, setAll, deleteOne, deleteMany, etc.

Example:

import { rxEffect } from '@ngrx/signals';
import { withEntities, setAll, deleteOne } from '@ngrx/signals/entity';
import { withCallState, setLoaded } from './call-state-feature';

const UsersStore = createSignalStore(
  withEntities<User>(),
  withCallState(),
  withEffects(({ update }) => {
    const usersService = inject(UsersService);

    return {
      loadUsers: rxEffect(
        pipe(
          tap(() => update({ callState: 'loading' })),
          exhaustMap(() => usersService.getAll()),
          tap((users) => update(setAll(users), setLoaded()))
        )
      ),
    };
  })
);

@Component({
  template: `
    <p>Users: {{ usersStore.entities() | json }}</p>
    <p *ngIf="usersStore.loading()">Loading ...</p>
    <button (click)="onDeleteOne()">Delete One</button>
  `,
  providers: [UsersStore],
})
export class UsersComponent implements OnInit {
  readonly usersStore = inject(UsersStore);

  ngOnInit(): void {
    this.usersStore.loadUsers();
  }

  onDeleteOne(): void {
    this.usersStore.update(deleteOne(1));
  }
}

withEntities function can be also used multiple times for the same store in case we want to have multiple collections within the same store:

import { withEntities, addOne, deleteOne } from '@ngrx/signals/entity';

const BooksStore = createSignalStore(
  withEntities<Book>({ collection: 'book' }),
  withEntities<Author>({ collection: 'author' })
);

const booksStore = inject(BooksStore);
// booksStore contains following properties:
// - booksStore.bookEntityMap: Signal<Dictionary<Book>>;
// - booksStore.bookIds: Signal<Array<string | number>>;
// - (computed) booksStore.bookEntities: Signal<Book[]>;
// - booksStore.authorEntityMap: Signal<Dictionary<Author>>;
// - booksStore.authorIds: Signal<Array<string | number>>;
// - (computed) booksStore.authorEntities: Signal<Author[]>;

// updating multiple collections:
booksStore.update(addOne({ id: 10, title: 'Book 1' }, { collection: 'book' }));
booksStore.update(deleteOne(100, { collection: 'author' }));

ng16-signal-store-playground's People

Contributors

markostanimirovic avatar dmorosinotto avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.