DEV Community

nsimonoski
nsimonoski

Posted on

7 Signal Store Features You Only Need to Write Once

The Repetition Problem

Every Angular store ends up doing the same things. A loading flag. An error message. Persistence to localStorage. A snackbar for success feedback. Reading query params from the router. Opening a dialog.

Without composition, this logic gets copied between stores - or leaks into components where it doesn't belong. Every store ends up with its own slightly different way of tracking "is this thing loading right now."

ngrx signal store solves this with signalStoreFeature. Beyond reducing duplication, these features decouple components from implementation details. When a UI library changes, an Angular API evolves, or you swap out a dependency - you update the feature once and every store that uses it stays untouched.


Quick Recap: What is signalStoreFeature?

signalStoreFeature lets you extract reusable store logic - state, computed signals, methods, hooks - into composable units:

export const MyStore = signalStore(
  { providedIn: 'root' },
  withState({ count: 0 }),
  withLoading(),
  withBrowserStorage({ key: 'my-store' }),
);
Enter fullscreen mode Exit fullscreen mode

TypeScript merges the types from all features, so autocompletion works across everything. For a deeper dive, check the official NgRx docs.


Feature 1: withLoading

Every store needs loading state and error tracking. Instead of redefining it everywhere:

import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';

export const withLoading = () =>
  signalStoreFeature(
    withState<{ isLoading: boolean; errorMessage: string }>({
      isLoading: false,
      errorMessage: '',
    }),
    withMethods((state) => ({
      setLoading(isLoading = true, errorMessage?: string): void {
        patchState(state, { isLoading, errorMessage });
      },
    })),
  );
Enter fullscreen mode Exit fullscreen mode

Components read userStore.isLoading() and userStore.errorMessage() as signals. One consistent API across every store. No more guessing whether it's loading$ or isLoading$ or isLoading.


Feature 2: withBrowserStorage

Wraps localStorage or sessionStorage with a configurable key:

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

interface BrowserStorageConfig {
  key: string;
  type?: 'local' | 'session';
}

function browserStorage<T>(key: string, options?: { type?: 'local' | 'session' }) {
  const storage = options?.type === 'session' ? sessionStorage : localStorage;

  return {
    load(): T | null {
      try {
        const raw = storage.getItem(key);
        return raw ? JSON.parse(raw) : null;
      } catch {
        return null;
      }
    },
    save(data: Partial<T>): void {
      const existing = this.load();
      storage.setItem(key, JSON.stringify({ ...existing, ...data }));
    },
    clearAll(preserveKeys: string[] = []): void {
      const preserved = preserveKeys.map((k) => [k, localStorage.getItem(k)] as const);
      localStorage.clear();
      sessionStorage.clear();
      preserved.forEach(([k, value]) => value && localStorage.setItem(k, value));
    },
  };
}

export const withBrowserStorage = (config: BrowserStorageConfig) => {
  const storage = browserStorage<Record<string, unknown>>(config.key, { type: config.type });

  return signalStoreFeature(
    withMethods((store) => ({
      loadFromStorage(): boolean {
        const saved = storage.load();
        if (!saved) return false;

        try {
          patchState(store, saved);
          return true;
        } catch {
          return false;
        }
      },
      saveToStorage(data: Record<string, unknown>): void {
        patchState(store, data);
        storage.save(data);
      },
      removeFromStorage(): void {
        storage.remove();
      },
      clearAllStorage(preserveKeys: string[] = []): void {
        storage.clearAll(preserveKeys);
      },
    })),
  );
};
Enter fullscreen mode Exit fullscreen mode

The feature is a factory - it takes config and returns a signalStoreFeature(...) call. The browserStorage helper wraps localStorage/sessionStorage with JSON serialization and merge-on-save semantics. The storage instance is closed over, so every store gets its own isolated storage. patchState(store, saved) hydrates the store from the persisted data on load.


Feature 3: withSnackbar

Every store that does async work needs to show feedback. Instead of injecting a snackbar service in every store, wrap it in a feature:

import { inject } from '@angular/core';
import { signalStoreFeature, withMethods, withProps } from '@ngrx/signals';
import { SnackbarService } from '@org/angular/ui';

export const withSnackbar = () =>
  signalStoreFeature(
    withProps(() => ({
      _snackbar: inject(SnackbarService),
    })),
    withMethods((store) => ({
      showSuccess(message?: string, duration?: number): void {
        store._snackbar.success(message, duration);
      },
      showError(message?: string, duration?: number): void {
        store._snackbar.error(message, duration);
      },
      showInfo(message?: string, duration?: number): void {
        store._snackbar.info(message, duration);
      },
      dismissSnackbar(): void {
        store._snackbar.dismiss();
      },
    })),
  );
Enter fullscreen mode Exit fullscreen mode

If you later switch to Angular Material's MatSnackBar or any other UI library, you update withSnackbar once. Every consumer keeps calling showSuccess().


Feature 4: withDialog

Confirmation dialogs need state for visibility, a message, and a way to carry context about what is being confirmed. The confirm action is different every time - delete a file, remove a user, discard changes. Without a callback, the component needs branching logic.

import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/signals';

interface DialogState {
  dialogOpen: boolean;
  dialogTitle: string;
  dialogMessage: string;
}

export const withDialog = () => {
  let onConfirm: (() => void) | null = null;

  return signalStoreFeature(
    withState<DialogState>({
      dialogOpen: false,
      dialogTitle: '',
      dialogMessage: '',
    }),
    withMethods((state) => ({
      openDialog(title: string, message: string, cb: () => void): void {
        onConfirm = cb;
        patchState(state, { dialogOpen: true, dialogTitle: title, dialogMessage: message });
      },
      confirmDialog(): void {
        onConfirm?.();
        onConfirm = null;
        patchState(state, { dialogOpen: false, dialogTitle: '', dialogMessage: '' });
      },
      closeDialog(): void {
        onConfirm = null;
        patchState(state, { dialogOpen: false, dialogTitle: '', dialogMessage: '' });
      },
    })),
  );
};
Enter fullscreen mode Exit fullscreen mode

The callback is closure-scoped, not serializable state:

@Component({
  template: `
    <ui-confirmation-dialog
      [isOpen]="store.dialogOpen()"
      [title]="store.dialogTitle()"
      [message]="store.dialogMessage()"
      (confirmed)="store.confirmDialog()"
      (cancelled)="store.closeDialog()"
    />
  `,
})
export class FileExplorerComponent {
  readonly store = inject(FileExplorerStore);

  handleDelete(node: FileNode): void {
    store.openDialog('Delete', `Delete "${node.name}"?`, () => store.delete(node.path));
  }

  handleRename(node: FileNode): void {
    store.openDialog('Rename', `Rename "${node.name}"?`, () => store.rename(node));
  }
}
Enter fullscreen mode Exit fullscreen mode

Feature 5: withRouting

Features can inject Angular services. withProps runs inside the store's injection context, so inject(Router) works:

import { signalStoreFeature, withMethods, withProps } from '@ngrx/signals';
import { ActivatedRoute, Data, NavigationEnd, Params, Router } from '@angular/router';
import { inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter, map } from 'rxjs';

export const withRouting = () =>
  signalStoreFeature(
    withProps(() => {
      const router = inject(Router);
      const activatedRoute = inject(ActivatedRoute);

      const navigationEnd$ = router.events.pipe(
        filter((event) => event instanceof NavigationEnd),
      );

      const currentUrl = toSignal(
        navigationEnd$.pipe(map((event) => event.urlAfterRedirects)),
        { initialValue: router.url },
      );

      const queryParams = toSignal(
        navigationEnd$.pipe(map(() => activatedRoute.snapshot.queryParams)),
        { initialValue: activatedRoute.snapshot.queryParams },
      );

      return { router, activatedRoute, navigationEnd$, currentUrl, queryParams };
    }),
    withMethods(({ router, activatedRoute }) => ({
      navigate(route: string): void {
        router.navigateByUrl(route);
      },
      openInNewTab(route: string, queryParams?: Record<string, unknown>): void {
        const urlTree = router.createUrlTree([route], { queryParams });
        const url = router.serializeUrl(urlTree);
        window.open(url, '_blank');
      },
      getRouteParams(): Params {
        return activatedRoute.snapshot.params;
      },
      getQueryParams(): Params {
        return activatedRoute.snapshot.queryParams;
      },
      getRouteData(): Data {
        return activatedRoute.snapshot.data;
      },
    })),
  );
Enter fullscreen mode Exit fullscreen mode

Bonus: A Route Tree Object

Pairs well with withRouting — all route paths in one place:

const ADMIN = '/admin';
const USERS = `${ADMIN}/users`;
const PRODUCTS = '/products';

export const AppRoutes = {
  auth: {
    login: '/login',
    logout: '/logout',
    forgotPassword: '/forgot-password',
    resetPassword: (token: string) => `/reset-password?token=${token}`,
  },
  dashboard: '/dashboard',
  admin: {
    root: ADMIN,
    settings: `${ADMIN}/settings`,
    users: {
      list: USERS,
      detail: (id: string) => `${USERS}/${id}`,
      edit: (id: string) => `${USERS}/${id}/edit`,
      invite: `${USERS}/invite`,
    },
  },
  products: {
    list: PRODUCTS,
    detail: (slug: string) => `${PRODUCTS}/${slug}`,
    category: (category: string) => `${PRODUCTS}?category=${encodeURIComponent(category)}`,
    search: (query: string) => `${PRODUCTS}?q=${encodeURIComponent(query)}`,
  },
  account: {
    profile: '/account/profile',
    billing: '/account/billing',
  },
} as const;
Enter fullscreen mode Exit fullscreen mode
store.navigate(AppRoutes.products.detail(product.slug));
store.navigate(AppRoutes.admin.users.edit(userId));

if (url.startsWith(AppRoutes.admin.root)) return 'admin';
Enter fullscreen mode Exit fullscreen mode

Feature 6: withGitActions - Domain Feature Composition

signalStoreFeature isn't just for infrastructure. It works for domain logic too. This feature bundles every git operation in the app:

import { inject } from '@angular/core';
import { signalStoreFeature, withMethods, withProps } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { uiStore } from '@org/angular/ui';
import { GitService } from '../git.service';
import { GitStatusStore } from '../ide-store';

export const withGitActions = () =>
  signalStoreFeature(
    uiStore.withSnackbar(),
    withProps(() => ({
      _gitService: inject(GitService),
      _gitStatusStore: inject(GitStatusStore),
    })),
    withMethods((store) => ({
      gitCommit: rxMethod<string>(
        pipe(
          switchMap((message: string) =>
            store._gitService.commit(store._gitStatusStore.rootPath(), message),
          ),
          tap(({ success, error }) => {
            if (!success) return store.showError(error);
            store.showSuccess('Committed!');
          }),
        ),
      ),
      gitPush: rxMethod<void>(
        pipe(
          switchMap(() => store._gitService.push(store._gitStatusStore.rootPath())),
          tap(({ success, error }) => {
            if (!success) return store.showError(error);
            store.showSuccess('Pushed!');
          }),
        ),
      ),
      gitStage: rxMethod<string[]>(
        pipe(
          switchMap((paths: string[]) =>
            store._gitService.stage(store._gitStatusStore.rootPath(), paths),
          ),
        ),
      ),
      // ... gitUnstage, gitDiscard, gitStash, gitStashPop, gitStashApply
    })),
  );
Enter fullscreen mode Exit fullscreen mode

Notice how withGitActions composes withSnackbar internally - the consuming store gets showSuccess() and showError() for free without knowing about SnackbarService. Private props use underscore prefixes (_gitService, _gitStatusStore) to signal "internal to this feature."


Feature 7: WebSocket Store

Real-time features need WebSocket connections - file watchers, git status updates, terminal sessions. A signal store wrapping socket.io gives you connection state as a signal and automatic event re-subscription on reconnect:

import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { io, Socket } from 'socket.io-client';
import { Observable } from 'rxjs';

export const WebSocketStore = signalStore(
  { providedIn: 'root' },
  withState<{ connected: boolean }>({ connected: false }),
  withMethods((store) => {
    const socket: Socket = io(WS_BASE_URL);
    const watchEvents: { event: string; data: unknown }[] = [];

    socket.on('connect', () => {
      patchState(store, { connected: true });
      for (const { event, data } of watchEvents) {
        socket.emit(event, data);
      }
    });
    socket.on('disconnect', () => patchState(store, { connected: false }));

    return {
      on<T>(event: string): Observable<T> {
        return new Observable<T>((subscriber) => {
          const handler = (data: T) => subscriber.next(data);
          socket.on(event, handler as (...args: unknown[]) => void);
          return () => socket.off(event, handler as (...args: unknown[]) => void);
        });
      },
      emit(event: string, data?: unknown): void {
        if (socket.connected) {
          socket.emit(event, data);
        }
      },
      watch(event: string, data?: unknown): void {
        const idx = watchEvents.findIndex((e) => e.event === event);
        if (idx !== -1) watchEvents.splice(idx, 1);
        watchEvents.push({ event, data });
        if (socket.connected) socket.emit(event, data);
      },
      reconnect(): void {
        socket.disconnect();
        socket.connect();
      },
      disconnect(): void {
        watchEvents.length = 0;
        socket.disconnect();
      },
    };
  }),
);
Enter fullscreen mode Exit fullscreen mode

watch registers an event that gets re-emitted on every reconnect. When the server restarts or the connection drops, the client automatically re-subscribes to file watchers, git watchers, etc.

export const GitStatusStore = signalStore(
  { providedIn: 'root' },
  withState({ branch: '', changesCount: 0 }),
  withProps(() => ({
    ws: inject(WebSocketStore),
  })),
  withMethods((store) => ({
    listenToGitChanges: rxMethod<void>(
      pipe(
        switchMap(() => store.ws.on<GitStatusResponse>(GIT_CHANGE_EVENT)),
        tap(({ branch, changesCount }) => patchState(store, { branch, changesCount })),
      ),
    ),
  })),
  withHooks({
    onInit(store) {
      store.ws.watch(GIT_WATCH_EVENT, rootPath);
      store.listenToGitChanges();
    },
  }),
);
Enter fullscreen mode Exit fullscreen mode

Composing Multiple Features

export const EditorPreferencesStore = signalStore(
  { providedIn: 'root' },
  withState({
    theme: 'dark' as 'dark' | 'light',
    fontSize: 14,
    wordWrap: true,
  }),
  withLoading(),
  withBrowserStorage({ key: 'editor-prefs' }),
  withSnackbar(),
  withMethods((store) => ({
    updateTheme(theme: 'dark' | 'light'): void {
      store.saveToStorage({ theme });
      store.showSuccess('Theme updated');
    },
  })),
);
Enter fullscreen mode Exit fullscreen mode

Patterns to Keep in Mind

Service injection works because stores run in an injection context. inject(Router) works inside withProps and withMethods. Features can't be called outside of store definitions.

Parameterized features are factories. withBrowserStorage({ key: '...' }) returns a signalStoreFeature(...) call. Config values are closed over.

Underscore prefix = internal. _gitService, _gitStatusStore signal "don't use from outside this feature."

Never use .subscribe() inside a feature. Use rxMethod with tapResponse or tap for async work. The store handles cleanup.

Order matters. withState must come before features that read its state. TypeScript will tell you when you get this wrong.


Conclusion

These 7 features replaced duplicated code across every store in the app. New cross-cutting concerns become a feature instead of copy-pasted logic.

The pattern works for domain logic too - withGitActions groups 8 related methods into one composable unit. Stores stay thin: some state, some features, a few custom methods.

There's a testing benefit too. Each feature is a self-contained unit with minimal dependencies - test it once, and every store that composes it gets that behavior for free. Consuming stores don't need to mock Router, localStorage, or SnackbarService - that's already covered by the feature's own tests. Store tests focus on domain logic only. More on testing these features in an upcoming post.

The code in this post comes from a portfolio project I use to experiment with patterns and ship features quickly (source on GitHub). These features make it easy to spin up new stores without re-solving the same problems. If you want to see how it fits into a larger monorepo with shared contracts and multiple frontends, I wrote about that in this post.

Top comments (0)