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' }),
);
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 });
},
})),
);
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);
},
})),
);
};
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();
},
})),
);
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: '' });
},
})),
);
};
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));
}
}
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;
},
})),
);
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;
store.navigate(AppRoutes.products.detail(product.slug));
store.navigate(AppRoutes.admin.users.edit(userId));
if (url.startsWith(AppRoutes.admin.root)) return 'admin';
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
})),
);
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();
},
};
}),
);
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();
},
}),
);
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');
},
})),
);
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)