Skip to content
This repository has been archived by the owner on Nov 21, 2023. It is now read-only.

markostanimirovic/ngrx-signal-store-playground

This branch is up to date with main.

Folders and files

NameName
Last commit message
Last commit date

Latest commit

6219b71 · Nov 21, 2023

History

37 Commits
Aug 15, 2023
Mar 3, 2023
Aug 14, 2023
Aug 15, 2023
Mar 3, 2023
Aug 15, 2023
Jun 27, 2023
Mar 4, 2023
Nov 21, 2023
Aug 7, 2023
Aug 7, 2023
Aug 15, 2023
Aug 15, 2023
Mar 4, 2023
Mar 4, 2023
Aug 13, 2023
Aug 15, 2023

Repository files navigation

[Archived] NgRx Signals

⚠️ This repository is archived. The source code of the @ngrx/signals package is available in the NgRx repository.

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
  • Flexible
  • Modular
  • Scalable
  • Performant and tree-shakeable
  • Strongly typed

Contents


signalState

The signalState function creates nested signals for the provided initial state.

import { signalState } from '@ngrx/signals';

const state = signalState({
  user: {
    firstName: 'John',
    lastName: 'Smith',
  },
  foo: 'bar',
  numbers: [1, 2, 3],
});

console.log(state()); // { user: { firstName: 'John', lastName: 'Smith' }, foo: 'bar' }
console.log(state.user()); // { firstName: 'John', lastName: 'Smith' }
console.log(state.user.firstName()); // 'John'

All nested signals are created lazily as requested. Besides that, the signalState will cache created signals, so they'll be created only the first time when requested.

const lastName1 = state.user.lastName; // Signal<string>
const lastName2 = state.user.lastName; // Signal<string>

console.log(lastName1 === lastName2); // true

$update Method

The signalState instance provides the $update method for updating the state. It accepts a sequence of partial state objects or updater functions that partially update the state.

// passing a partial state object
state.$update({ foo: 'baz', numbers: [10, 20, 30] });

// passing an updater function
state.$update((state) => ({
  user: { ...state.user, firstName: 'Peter' },
  foo: 'bar',
}));

// passing a sequence of partial state objects and/or updater functions
state.$update(
  (state) => ({
    numbers: [...state.numbers, 4],
    user: { ...state.user, lastName: 'Ryan' }
  }),
  { foo: 'bar' },
);

This provides the ability to define reusable updater functions that can be used for any signalState instance that has a specific state slice.

import { signalState, SignalStateUpdater } from '@ngrx/signals';

function setFirstName(firstName: string): SignalStateUpdater<{ user: User }> {
  return (state) => ({ user: { ...state.user, firstName } });
}

function addNumber(num: number): SignalStateUpdater<{ numbers: number[] }> {
  return (state) => ({ numbers: [...state.numbers, num] });
}

// usage:
const state1 = signalState({
  user: { firstName: 'John', lastName: 'Ryan' },
  numbers: [10, 20, 30],
  label: 'ngrx',
});

const state2 = signalState({
  user: { firstName: 'Peter', lastName: 'Smith' },
  numbers: [1, 2, 3],
});

state1.$update(setFirstName('Marko'), addNumber(40), { label: 'signals' });
state2.$update(setFirstName('Alex'), addNumber(4));

Unlike the default behavior of Angular signals, all signals created by the signalState function use equality check for reference types, not only for primitives. Therefore, the $update method only supports immutable updates. To perform immutable updates in a mutable way, use the immer library:

// immer-updater.ts
import { SignalStateUpdater } from '@ngrx/signals';
import { produce } from 'immer';

export function immerUpdater<State extends Record<string, unknown>>(
  updater: (state: State) => void
): SignalStateUpdater<State> {
  return (state) => produce(state, (draft) => updater(draft as State));
}

The immerUpdater function can be used in the following way:

import { immerUpdater } from './immer-updater';

state.$update(
  immerUpdater((state) => {
    state.user.firstName = 'Alex';
    state.numbers.push(100);
  })
);

selectSignal

The selectSignal function is used to create computed (derived) signals. Unlike computed function from the @angular/core package, the selectSignal function applies an equality check for reference types by default. It has the same signature as computed.

import { selectSignal, signalState } from '@ngrx/signals';

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

const usersState = signalState<UsersState>({
  users: [],
  query: '',
});

const filteredUsers = selectSignal(() =>
  usersState.users().filter(({ name }) => name.includes(usersState.query()))
);

It also has another signature similar to createSelector and ComponentStore.select:

const filteredUsers = selectSignal(
  usersState.users,
  usersState.query,
  (users, query) => users.filter(({ name }) => name.includes(query))
);

signalStore

The signalStore function acts as a pipe that accepts a sequence of store features. By combining various store features, we can add state slices, computed signals, methods, and hooks to the signal store.

There are 4 base features that can be used to create signal stores or custom signal store features: withState, withSignals, withMethods, and withHooks.

The withState feature accepts a dictionary of state slices, and converts each slice into a nested signal. All nested signals are created lazily as requested.

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

type Filter = { query: string; pageSize: number };
type UsersState = { users: User[]; filter: Filter };

const initialState: UsersState = {
  users: [],
  filter: { query: '', pageSize: 10 },
};

const UsersStore = signalStore(
  withState(initialState)
);

The signalStore function returns a class/token that can be further provided and injected where needed. Similar to signalState, signalStore also provides the $update method for updating the state.

@Component({
  providers: [UsersStore],
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
  // available state signals:
  // - usersStore.users: Signal<User[]>
  // - usersStore.filter: Signal<{ query: string; pageSize: number }>
  // - usersStore.filter.query: Signal<string>
  // - usersStore.filter.pageSize: Signal<number>
  
  onAddUser(user: User): void {
    this.usersStore.$update((state) => ({
      users: [...state.users, user]
    }));
  }
  
  onUpdateFilter(filter: Filter): void {
    this.usersStore.$update({ filter });
  }
}

The withState feature also has a signature that accepts the initial state factory as an input argument.

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

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

const USERS_INITIAL_STATE = new InjectionToken<UsersState>('Users Initial State', {
   factory() {
     return { users: [], query: '' };
   }, 
});

const UsersStore = signalStore(
  withState(() => inject(USERS_INITIAL_STATE))
);

The withSignals feature accepts the computed signals factory as an input argument. Its factory accepts a dictionary of previously defined state and computed signals as an input argument.

import { selectSignal, signalStore, withState, withSignals } from '@ngrx/signals';

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

const UsersStore = signalStore(
  withState<UsersState>({ users: [], query: '' }),
  // we can access previously created state signals via factory input
  withSignals(({ users, query }) => ({
    filteredUsers: selectSignal(() =>
      users().filter(({ name }) => name.includes(query()))
    ),
  }))
);

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

The withMethods feature provides the ability to add methods to the signal store. Its factory accepts a dictionary of previously defined state signals, computed signals, methods, and $update method as an input argument and returns a dictionary of methods.

The last base feature of SignalStore is withHooks. It provides the ability to add custom logic on SignalStore init and/or destroy.

import { signalStore, withState, withMethods, withHooks } from '@ngrx/signals';

type UsersState = { users: User[]; loading: boolean };

const UsersStore = signalStore(
  withState<UsersState>({ users: [], loading: false }),
  // we can access the '$update' method via factory input
  withMethods(({ $update, users }) => {
    // services/tokens can be injected here
    const usersService = inject(UsersService);

    return {
      addUser(user: User): void {
        $update((state) => ({
          users: [...state.users, user],
        }));
      },
      async loadUsers(): Promise<void> {
        $update({ loading: true });
        const users = await usersService.getAll();
        $update({ users, loading: false });
      },  
    };
  }),
  withHooks({
    onInit({ loadUsers }) {
      loadUsers();
    },
    onDestroy({ users }) {
      console.log('users on destroy', users());
    },
  })
);

@Component({
  providers: [UsersStore],
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
  // available signals and methods:
  // - usersStore.$update
  // - usersStore.users: Signal<User[]>
  // - usersStore.loading: Signal<boolean>
  // - usersStore.addUser: (user: User) => void
  // - usersStore.loadUsers: () => Promise<void>
}

DI Config

In the previous examples, we saw the default behavior - signalStore returns a class/token that can be further provided where needed. However, we can also provide a SignalStore at the root level by using the { providedIn: 'root' } config:

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

type UsersState = { users: User[] };

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

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

Defining Stores as Classes

Besides the functional approach, we can also define a store as class in the following way:

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

type CounterState = { count: number };

const initialState: CounterState = { count: 0 };

@Injectable({ providedIn: 'root' })
export class CounterStore extends signalStore(withState(initialState)) {
  // this.count signal is available from the base class
  readonly doubleCount = selectSignal(() => this.count() * 2);
  
  increment(): void {
    // this.$update method is available from the base class
    this.$update({ count: this.count() + 1 });
  }
  
  decrement(): void {
    this.$update({ count: this.count() - 1 });
  }
}

Custom Store Features

The @ngrx/signals package provides the signalStoreFeature function that can be used to create custom SignalStore features.

// call-state.feature.ts
import { selectSignal, signalStoreFeature, withState, withSignals } from '@ngrx/signals';

export type CallState = 'init' | 'loading' | 'loaded' | { error: string };

export function withCallState() {
  return signalStoreFeature(
    withState<{ callState: CallState }>({ callState: 'init' }),
    withSignals(({ callState }) => ({
      loading: selectSignal(() => callState() === 'loading'),
      loaded: selectSignal(() => callState() === 'loaded'),
      error: selectSignal(callState, (callState) =>
        typeof callState === 'object' ? callState.error : null
      ),
    }))
  );
}

export function setLoading(): { callState: CallState } {
  return { callState: 'loading' };
}

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

export function setError(error: string): { callState: CallState } {
  return { callState: { error } };
}

The withCallState feature can be further used in any signal store as follows:

import { signalStore, withState, withMethods } from '@ngrx/signals';
import { withCallState, setLoaded } from './call-state.feature';

type UsersState = { users: User[] };

const UsersStore = signalStore(
  { providedIn: 'root' },
  withState<UsersState>({ users: [] }),
  withCallState(),
  withMethods((store, usersService = inject(UsersService)) => ({
    async loadUsers() {
      // updating the state:
      store.$update({ callState: 'loading' });
      const users = await usersService.getAll();
      // or we can use reusable updater:
      store.$update({ users }, setLoaded());
    }
  }))
);

@Component({ /* ... */})
export class UsersComponent implements OnInit {
  readonly usersStore = inject(UsersStore);
  // available signals:
  // - usersStore.users: Signal<User[]>
  // - usersStore.callState: Signal<CallState>
  // - usersStore.loading: Signal<boolean>
  // - usersStore.loaded: Signal<boolean>
  // - usersStore.error: Signal<string | null>

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

The signalStoreFeature function also provides the ability to specify which state slices, computed signals, and/or methods are required in a store that uses the feature. This can be done using the type helper function.

// selected-entity.feature.ts
import { selectSignal, signalStoreFeature, type, withState, withSignals } from '@ngrx/signals';

type SelectedEntityState = { selectedEntityId: string | number | null };

export function withSelectedEntity<T extends { id: string | number }>() {
  return signalStoreFeature(
    // a store that uses 'withSelectedEntity' feature must have the 'entityMap' state slice
    { state: type<{ entityMap: Dictionary<T> }>() },
    withState<SelectedEntityState>({ selectedEntityId: null }),
    withSignals(({ selectedEntityId, entityMap }) => ({
      selectedEntity: selectSignal(
        selectedEntityId,
        entityMap,
        (selectedEntityId, entityMap) => selectedEntityId
          ? entityMap[selectedEntityId]
          : null
      )
    }))
  );
}

If we try to use the withSelectedEntity feature in the store that doesn't contain entityMap state slice, the compilation error will be thrown.

import { signalStore, type, withState } from '@ngrx/signals';
import { withSelectedEntity } from './selected-entity.feature';

const UsersStoreWithoutEntityMap = signalStore(
  withState<{ query: string }>({ query: '' }),
  withSelectedEntity() // ❌ compilation error
);

const UsersStoreWithEntityMap = signalStore(
  withState<{ query: string; entityMap: Dictionary<User> }>({
    query: '',
    entityMap: {},
  }),
  withSelectedEntity(), // ✅
);

Besides state, we can also add constraints for computed signals and/or methods in the following way:

import { signalStoreFeature, type } from '@ngrx/signals';

export function withMyFeature() {
  return signalStoreFeature(
    {
      // a store that uses 'withMyFeature' must have the 'foo' state slice,
      state: type<{ foo: string }>(),
      // 'bar' computed signal,
      signals: type<{ bar: Signal<number> }>(),
      // and 'loadBaz' method
      methods: type<{ loadBaz: () => Promise<void> }>(),
    },
    /* ... */
  );
}

More examples of custom SignalStore features:

withImmerUpdate and withStorageSync features can be developed as community plugins in the future.

import { signalStore, withState } from '@ngrx/signals';
import { withLocalStorageSync } from '../shared/local-storage-sync.feature';
import { withImmerUpdate } from '../shared/immer-update.feature';

@Injectable({ providedIn: 'root' })
export class TodosStore extends signalStore(
  withState<{ todos: string[] }>({ todos: [] }),
  // synchronize todos state with localStorage item with key 'todos'
  withStorageSync('todos'),
  // override $update method to use `immerUpdater` under the hood by default
  withImmerUpdate()
) {
  addTodo(todo: string): void {
    this.$update((state) => {
      state.todos.push(todo);
    });
  }

  removeTodo(index: number): void {
    this.$update((state) => {
      state.todos.splice(index, 1);
    });
  }
}

rxMethod

The rxMethod function is inspired by the ComponentStore.effect method. It provides the ability to create reactive methods by using RxJS operators. It returns a function that accepts a static value, signal, or observable as an input argument.

The rxMethod function can be used in the following way:

import { rxMethod } from '@ngrx/signals/rxjs-interop';
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 = rxMethod<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 method 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 method
    // will be executed only once.
    // If it's called with observable (loadUsers(query$)), the method
    // will be executed every time when 'query$' observable emits a new
    // value.
  }
}

It can be also used to define SignalStore methods:

import { signalStore, withState, withMethods, withHooks } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';

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

const UsersStore = signalStore(
  withState<UsersState>({ users: [], query: '' }),
  withMethods((store, usersService = inject(UsersService)) => ({
    loadUsersByQuery: rxMethod<string>(
      pipe(
        tap(() => store.$update({ loading: true })),
        switchMap((query) => usersService.getByQuery(query)),
        tap((users) => store.$update({ users, loading: false }))
      )
    ),
  })),
  withHooks({
    onInit({ loadUsersByQuery, query }) {
      // re-fetch users every time when query signal changes
      loadUsersByQuery(query);
    },
  })
);

Entity Management

This package should provide the following APIs:

  • withEntities feature that will add entityMap and ids as state slices, and entities (entity list) as computed signal
  • tree-shakeable updater functions: setOne, setAll, deleteOne, deleteMany, etc.
import { signalStore, withMethods } from '@ngrx/signals';
import { withEntities, setAll, deleteOne } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { withCallState, setLoading, setLoaded } from './call-state.feature';

const UsersStore = signalStore(
  withEntities<User>(),
  withCallState(),
  withMethods((store, usersService = inject(UsersService)) => ({
    loadUsers: rxMethod<void>(
      pipe(
        tap(() => store.$update(setLoading())),
        exhaustMap(() => usersService.getAll()),
        tap((users) => store.$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 if we want to have multiple collections within the same store:

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

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

const booksStore = inject(BooksStore);
// booksStore contains the following signals:
// - 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' }));