angular
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,
});
4. Loose Coupling Is Not Always Worth the Cost
Loose coupling can be useful, but it is not free. Consider a profile page that updates a user’s email address. After the update, the page shows a message and returns to the dashboard. One page owns the workflow, and the order is known:
async onSaveEmail(newEmail: string) {
try {
// 👇 ✅ The complete workflow is easy to see
await this.profileService.updateEmail(newEmail);
this.toast.show("Email updated successfully!");
await this.router.navigate(["/dashboard"]);
} catch {
this.toast.showError("Failed to update email.");
}
}
The component is directly coupled to ProfileService, ToastService, and
Router. That is intentional. These calls belong to one workflow, so keeping
them together makes the order easy to find, read, and debug.
We can also implement it with valid, event-style NgRx actions:
// profile.component.ts
onSaveEmail(newEmail: string) {
this.store.dispatch(
profilePageEvents.emailChangeSubmitted({ newEmail }),
);
}
// profile.effects.ts
updateEmail$ = createEffect(() =>
this.actions$.pipe(
ofType(profilePageEvents.emailChangeSubmitted),
exhaustMap(({ newEmail }) =>
this.api.updateEmail(newEmail).pipe(
map((user) => profileApiEvents.emailUpdated({ user })),
catchError((error) =>
of(profileApiEvents.emailUpdateFailed({ error })),
),
),
),
),
);
showSuccessAndLeave$ = createEffect(
() =>
this.actions$.pipe(
ofType(profileApiEvents.emailUpdated),
tap(() => {
this.toast.show("Email updated successfully!");
this.router.navigate(["/dashboard"]);
}),
),
{ dispatch: false },
);
showFailure$ = createEffect(
() =>
this.actions$.pipe(
ofType(profileApiEvents.emailUpdateFailed),
tap(() => this.toast.showError("Failed to update email.")),
),
{ dispatch: false },
);
// user.reducer.ts
on(profileApiEvents.emailUpdated, (state, { user }) => ({
...state,
currentUser: user,
}));
This NgRx version is more loosely coupled. The component does not know which effects or reducers handle its event, and another handler can be added without changing the component. However, the amount of code has increased dramatically. The actions and their handlers are still coupled, and navigating between them is much harder.
If you have a known workflow with a fixed sequence of steps, you probably do not need NgRx Store. Calling services directly is the simpler and clearer choice.
Do Not Make NgRx Store a Project-wide Rule
The four 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.
- Loose coupling may not repay its cost for a workflow with one owner.
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.