If you’ve been working with NgRx SignalStore for a while, you’ve probably noticed a pattern: you keep writing the same pieces of code over and over again.
Logging state changes. Resetting the store to its initial state. Managing filters and exposing update methods. None of these are complex, but they show up in almost every store you create.
At first, it doesn’t feel like a problem. Copy, paste, tweak—move on. But as your application grows, this repetition starts to add up. What was once “just a few lines” becomes boilerplate scattered across your codebase, making things harder to maintain and easier to get wrong.
The issue isn’t duplication itself—it’s the absence of a shared abstraction for these recurring patterns.
And that’s exactly where custom SignalStore features come in.
In this article, I want to share a few Custom SignalStore feature that I commonly use.
withLogger
Creates a feature that logs every state change.
export function withLogger(name: string) {
return signalStoreFeature(
withHooks((store) => {
const logger = inject(LOGGER);
return {
onInit() {
watchState(store, (state) => {
logger.debug(`${name} state changed`, state);
});
},
};
}),
);
}
withReset
Creates a feature that exposes a reset method on the store, allowing it to reset its state to the initial value
export function withReset<T extends object>(initialState: T) {
return signalStoreFeature(
withMethods((store) => ({
reset: () => patchState(store, { ...initialState }),
})),
withHooks((store) => {
return {
onDestroy: () => {
store.reset();
},
};
}),
);
}
withFilters
Creates a feature that will expose filters and an updateFilters method to read and update the store's filter state
export const withFilters = <
T extends Record<string, unknown>,
State extends Record<string, unknown>,
Props extends Record<string, unknown>,
Methods extends Record<string, (...params: any[]) => unknown>,
>(
initialFilters: T,
) => {
return signalStoreFeature(
{ state: type<State>(), props: type<Props>(), methods: type<Methods>() },
withState<T>(() => ({ ...initialFilters })),
withComputed(() => {
const filters = computed<T>(() => ({ ...initialFilters}));
return { filters };
}),
withMethods((store) => {
const updateFilters = (filters: Partial<T> = {}) => {
const newFilters = { ...initialFilters, ...filters };
if (!objectEquals(newFilters, store.filters())) {
patchState<T>(store, newFilters);
}
};
return { updateFilters };
}),
);
};
Use all the previous Custom SignalStore features
Now we can use these custom features like this:
// contacts.state.ts
export type ContactData = {
id: string;
email: string;
firstName: string;
lastName: string;
};
export declare const ContactType: {
readonly USER: 'USER';
readonly ADMIN: 'ADMIN';
};
export type ContactType = (typeof ContactType)[keyof typeof ContactType];
export type ContactFilters = {
contactType: ContactType | null;
search: string | null;
};
export type ContactsState = {
contacts: ContactData[];
};
export const initialContactsFilters: ContactFilters = {
contactType: null,
search: null,
};
export const initialContactsState: ContactsState = {
contacts: [],
};
// contacts.store.ts
export const ContactsStore = signalStore(
withState(initialContactsState),
withLogger('[CONTACTS STORE]'),
withReset(initialContactsState),
withFilters(initialContactsFilters),
withMethods((store, contactService = inject(ContactsService)) => {
const loadContacts = async () => {
const filters = store.filters();
const result = await lastValueFrom(contactService.loadContacts(filters));
patchState(store, { contacts: result });
};
return {
loadContacts,
};
}),
);
export type ContactsStore = InstanceType<typeof ContactsStore>;
Why this matters in real projects
In small examples, repeating a few lines of code doesn't seem like a big deal. But in real-world applications, you rarely have just one or two stores-you often have dozens.
If every store implements logging, reset logic, or filters slightly differently, it becomes harder to reason about behavior, onboard new developers, or refactor safely. Even small inconsistencies can lead to subtle bugs or unexpected side effects.
Custom SignalStore features help you standardize these patterns. Instead of rewriting the same logic multiple times, you define it once and reuse it everywhere. This reduces boilerplate, improves readability, and makes your stores more declarative—focused on what they do rather than how they do it.
It also makes evolution easier. If you need to change how logging works or update your filter logic, you do it in one place instead of hunting through the entire codebase.
Trade-offs
Of course, introducing custom SignalStore features isn’t free.
The first trade-off is abstraction overhead. For someone new to the codebase, it’s not always obvious what a custom feature does without jumping into its implementation. Overusing abstractions can make simple logic feel harder to follow.
There’s also a risk of over-engineering. Not every repeated line of code needs to become a reusable feature. If the abstraction is used only once or twice, it might add more complexity than value.
Another consideration is flexibility. When logic is centralized, it can become harder to handle edge cases that don’t fit the abstraction cleanly. You may end up adding configuration options that make the feature more complex than the original duplication.
The key is balance: extract patterns that are truly repeated and stable, but keep things simple when the benefit of abstraction isn’t clear.
Top comments (0)