Skip to content

gabrielguerrero/ngrx-signal-store-playground

 
 

Repository files navigation

NgRx Signals

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' }));

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 99.4%
  • Other 0.6%