In an earlier post, Think in Events: A Better Mental Model for NgRx Store, I explained why NgRx actions should describe events instead of commands. That post is about how to use NgRx Store more effectively. This post asks a different question: When should we not use NgRx Store at all?

I have worked on many Angular projects that use NgRx Store. In some of them, almost everything goes through Store: form values, API calls, notifications, analytics, and even simple utility calls. NgRx Store is useful when the problem needs it, but using it for every feature can turn simple code into actions, effects, reducers, and selectors. This is a good example of the saying, โ€œWhen your only tool is a hammer, every problem starts to look like a nail.โ€ Letโ€™s look at a few cases where NgRx Store is not needed.

1. Immutable Runtime Configuration Does Not Need Store

Consider configuration loaded from a server when the application starts. It does not change during that session. If you put it in NgRx Store, the code might look like this:

// configuration.reducer.ts
interface ConfigurationState {
  readonly configuration: AppConfiguration | null;
}

export const configurationReducer = createReducer(
  { configuration: null } as ConfigurationState,
  on(configurationApiEvents.loaded, (state, { configuration }) => ({
    ...state,
    configuration,
  })),
);

// configuration.effects.ts
@Injectable()
export class ConfigurationEffects {
  private readonly actions$ = inject(Actions);
  private readonly http = inject(HttpClient);

  readonly loadConfiguration$ = createEffect(() =>
    this.actions$.pipe(
      ofType(applicationEvents.started),
      switchMap(() =>
        this.http.get<AppConfiguration>("/api/configuration"),
      ),
      map((configuration) =>
        configurationApiEvents.loaded({ configuration }),
      ),
    ),
  );
}

// order-api.service.ts
loadOrders() {
  // ๐Ÿ‘‡ โŒ Unnecessary reactive plumbing for a value that never changes
  return this.store.select(selectConfiguration).pipe(
    filter(
      (configuration): configuration is AppConfiguration =>
        configuration !== null,
    ),
    take(1),
    switchMap((configuration) =>
      this.http.get(`${configuration.apiUrl}/orders`),
    ),
  );
}

The action, effect, reducer, and selector can all be implemented correctly, but the consumer now handles an observable and a temporary null state for a value that cannot change. The HTTP request is asynchronous; the resulting dependency does not need to remain asynchronous or reactive forever.

Load it before bootstrap completes, then expose it as a readonly POJO:

export interface AppConfiguration {
  readonly apiUrl: string;
}

@Injectable({ providedIn: "root" })
export class ConfigurationService {
  private configuration!: AppConfiguration;
  private readonly http = inject(HttpClient);

  async load(): Promise<void> {
    this.configuration = await firstValueFrom(
      this.http.get<AppConfiguration>("/api/configuration"),
    );
  }

  get value(): AppConfiguration {
    return this.configuration;
  }
}

bootstrapApplication(AppComponent, {
  providers: [
    provideAppInitializer(() =>
      inject(ConfigurationService).load(),
    ),
  ],
});

// order-api.service.ts
private readonly configuration =
  inject(ConfigurationService).value;

loadOrders() {
  // ๐Ÿ‘‡ โœ… The configuration is available synchronously
  return this.http.get(
    `${this.configuration.apiUrl}/orders`,
  );
}

This example assumes the configuration is fixed for the entire session. If feature flags refresh, the active tenant changes, or settings can be updated at runtime, then the value has real transitions and may need a reactive model.

2. Local Component State Does Not Need Global Store

Suppose a simple UI asks โ€œWhat is 1 + 1?โ€ The user enters an answer, and the component displays whether it is correct.

The component uses this simple template:

<!-- quiz.component.html -->
<section>
  <label for="answer">What is 1 + 1?</label>
  <input id="answer" type="number" [formControl]="answer" />

  @if (answer.dirty) {
  <p>{{ isCorrect() ? 'Correct!' : 'Try again.' }}</p>
  }
</section>

The input is state, but it belongs to this component. We could still build a valid NgRx implementation:

// quiz.actions.ts
export const quizPageEvents = createActionGroup({
  source: "Quiz Page",
  events: {
    answerChanged: props<{ answer: number | null }>(),
  },
});

// quiz.reducer.ts
const initialState = {
  answer: null as number | null,
};

type QuizState = typeof initialState;

export const quizReducer = createReducer(
  initialState,
  on(quizPageEvents.answerChanged, (state, { answer }) => ({
    ...state,
    answer,
  })),
);

// quiz.selectors.ts
const selectQuizState = createFeatureSelector<QuizState>("quiz");

export const selectIsCorrect = createSelector(
  selectQuizState,
  (state) => state.answer === 2,
);

// quiz.component.ts
@Component({
  /* ... */
})
export class QuizComponent {
  private readonly store = inject(Store);

  readonly answer = new FormControl<number | null>(null);
  // ๐Ÿ‘‡ โŒ Local state is read from the global Store
  readonly isCorrect = this.store.selectSignal(selectIsCorrect);

  constructor() {
    this.answer.valueChanges.pipe(takeUntilDestroyed()).subscribe((answer) => {
      // ๐Ÿ‘‡ โŒ Local state is copied to the global Store
      this.store.dispatch(quizPageEvents.answerChanged({ answer }));
    });
  }
}

There is nothing obviously wrong with the NgRx mechanics. The action describes an event, the reducer is pure, and the selector derives the result. The mistake is choosing an application-wide Store for state owned by one component. The FormControl already owns the answer, so the entire use case can remain local:

@Component({
  /* ... */
})
export class QuizComponent {
  readonly answer = new FormControl<number | null>(null);

  private readonly answerValue = toSignal(this.answer.valueChanges, {
    initialValue: null,
  });

  // ๐Ÿ‘‡ โœ… Local state stays local
  readonly isCorrect = computed(() => this.answerValue() === 2);
}

The difference is not merely the number of lines. Local state naturally follows the component lifecycle. When QuizComponent is destroyed and nothing else references it, the control and signals become eligible for garbage collection.

Because the quiz slice is registered in the root Store, destroying the component does not remove that state. The Store still references it, so it remains until a reducer explicitly resets it or the Store itself is destroyed. takeUntilDestroyed cleans up the componentโ€™s subscription; it does not clean up Store state. Adding a closed action and a reset reducer only introduces lifecycle coordination to imitate what local state already provides automatically.

Global scope also changes the meaning of multiple component instances:

<app-quiz /> <app-quiz />

With local state, each component has an independent answer. With the global quiz slice, both components observe the same answer unless the Store model, actions, and selectors all introduce instance identifiers.

3. Stateless Operations Do Not Need Actions and Effects

Effects are designed for side effects, but this does not mean every side effect needs an action and an effect.

Suppose a workflow needs to write an audit record. A team that routes everything through NgRx may add this extra code:

// audit.effects.ts
@Injectable()
export class AuditEffects {
  private readonly actions$ = inject(Actions);
  private readonly auditService = inject(AuditService);

  readonly recordAudit$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(auditActions.recordRequested),
        tap(({ name, properties }) => {
          // ๐Ÿ‘‡ The service call is wrapped in an effect
          this.auditService.record(name, properties);
        }),
      ),
    { dispatch: false },
  );
}

// ๐Ÿ‘‡ โŒ An action triggers an effect, which calls the service
store.dispatch(
  auditActions.recordRequested({
    name: "ticket_assignment_requested",
    properties: { ticketId, agentId },
  }),
);

The component already knows what it wants to do: call AuditService.record(). Dispatching an action only puts an effect between the component and the service. Calling the service directly is simpler:

// ๐Ÿ‘‡ โœ… Call the service directly
auditService.record("ticket_assignment_requested", {
  ticketId,
  agentId,
});

Do Not Make NgRx Store a Project-wide Rule

The three examples in this post have the same problem: NgRx Store adds more steps without solving a real need.

  • Configuration that never changes can be a readonly value in a service.
  • State used by one component can stay inside that component.
  • A simple operation can call a service directly.

If a project has NgRx Store, that does not mean every feature must use it. Before adding actions, reducers, effects, and selectors, ask:

What problem will NgRx Store solve here?

Use NgRx Store when its separation is usefulโ€”for example, when several parts of the application need to react to the same business events or when action history makes debugging easier. Otherwise, use the simpler tool.

Good architecture is not about using the most tools. It is about using only the tools the problem needs.