DEV Community

Simone Boccato
Simone Boccato

Posted on

NgRx - Custom SignalStore Feature

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);
          });
        },
      };
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
        },
      };
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
    }),
  );
};
Enter fullscreen mode Exit fullscreen mode

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: [],
};
Enter fullscreen mode Exit fullscreen mode
// 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>;
Enter fullscreen mode Exit fullscreen mode

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)