When Not to Use NgRx Store
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.