Intro
Below is a curated set of guidelines shaped by hands-on experience across Angular projects of varied complexity. Having hit several pitfalls and overengineered solutions where simplicity was needed, I settled on these principles, and they've held up well in every recent project I've been part of. I'm sharing them here in the hope they prove equally useful to others in the community.
Wherever practical, the rules come with ❌ Avoid and ✅ Prefer code samples, along with the rationale driving each choice. The opening two rules lay the groundwork, so they get a bit more depth; the remaining ones are concise, actionable pointers.
💡 Keep in mind this article stays clear of signals territory. Signals are promising and unlock fresh approaches to synchronous reactivity within Angular—they're bound to reshape state management significantly. Yet, I think we still need time before robust conventions solidify here. So, this read focuses on time-tested practices I can stand behind.
Rule 1: Use dedicated tools for state management
Angular ships with a rich set of built-in capabilities for crafting sophisticated apps, which naturally leads many developers to question the necessity of third-party state management libraries. On the surface, that skepticism is justified—why pull in extra dependencies and overhead when services, Subject, BehaviorSubject, and a full suite of RxJs operators are already at hand? Yet, my stance is to embrace a dedicated library, and I'd like to walk through how I arrived at that position.
It's true that state management can be implemented purely with Angular's core features and RxJs, no external help needed. However, that very flexibility opens a can of worms when we think about long-term codebase health. Picture a squad of developers each handling distinct parts of our application:
The first dev leans on plain class fields with imperative updates.
public synchronousState: State = initialState;
Another engineer leans toward reactive paradigms and handles state within their feature using Subject and BehaviorSubject.
public behaviourSubjectState$ = new BehaviorSubject<State>(initialState);
public subjectState$ = new Subject<State>();
The third developer leans deeper into the reactive paradigm, choosing to multicast the state through the shareReplay operator, thus guaranteeing that no consumer triggers the creation of a fresh observable:
public
subjectStateMulticasted$ = this.subjectState$.pipe(
shareReplay({ shareReplay: 1, refCount: true })
);
A fourth developer follows the same path, though this one isn’t deeply familiar
with Angular or RxJs and therefore misses the finer points of the shareReplay
operator, skipping refCount entirely:
public anotherSubjectStateMulticated$ = this.subjectState$.pipe(
shareReplay(1)
);
Let me take a quick detour here. In certain scenarios that are fairly easy to hit, that kind of shareReplay setup can cause a memory leak, and in worse cases, a browser crash with an "Out of memory" error. This happens because subscriptions to the source observable created by shareReplay never get unsubscribed. RxJs is powerful, but with great power comes great responsibility. I won't dig deeper into this topic right now; you can explore the specifics of the shareReplay behavior in RxJS: What's Changed with shareReplay?.
The fifth developer, however, is mostly coming from a server-side background, with years of experience in object-oriented programming. As such, they'll lean toward using private fields combined with getters and setters:
private _getterAndSetterState: State = initialState;
public get getterAndSetterState(): State {
return this._getterAndSetterState;
}
public set getterAndSetterState(state): void {
return this._getterAndSetterState = state;
}
Here are a few cases that show how side effects can be handled.
A straightforward function that invokes additional functions:
public simpleMethodForSideEffect(state: State): void {
const data = this.someService.calculateData();
this.someOtherService.doSomethingWithData(data);
}
A subscription that persists over time, watching a trigger observable and invoking one or more callbacks:
public observableListenerForSideEffect$ = this.someSubject$.pipe(
tap(() => {
const data = this.someService.calculateData();
this.someOtherService.doSomethingWithData(data);
})
).subscribe();
The functionality our team builds will typically need to hold some state while also managing side effects—pretty much any CRUD scenario fits this pattern. With that in mind, we can take these 5 options and multiply by 2, since there is no rule preventing us from combining them. Then let's double it once more, because certain developers will keep state and effects inside component classes, while others will delegate them to services. Some of those services will probably feature providedIn: 'root', meaning once instantiated for a particular screen the user visits, they will remain in memory even when no longer required. Other services will be tied to the lifetime of the components they support.
This by no means covers every possible combination. The choices of each hypothetical developer might not be inherently flawed. However, the core issue is, for each scenario outlined here:
- I kick off work on a fresh feature.
- I have to dig into someone else's existing feature to fix a bug or implement an update.
- A junior developer joins the team and needs to hit the ground running.
Every one of those varied approaches scattered across the project will work against me. The codebase becomes tough to follow, and there’s no standard solution to reach for when building something new—you end up drowning in the sheer range of existing patterns and might choose the wrong one only because it mirrors what’s in a nearby .ts file.
That conclusion might sound a bit like stating the obvious. Yet from what I have seen, the temptation to simply rely on BehaviourSubject remains strong until you reach that late point where the codebase is massive and tough to restructure. Avoiding third-party dependencies isn’t always a mistake. But take it from my experience: state and side-effect management is not the territory where you want to reinvent the wheel.
It is also common to overestimate how well you and your peers can stay consistent with a chosen pattern. Don’t place that additional load on yourself or your team. Shift that responsibility to a well-supported library with an easy-to-use API. Keep reading below for more details ⬇️.
Rule 2: Always prefer local state unless you have a good reason to use global state
💡To dive into the comparison between global and local state strategies, this segment examines working examples of both approaches: NGRX global store and NGRX component store. They belong to the broader @ngrx package family and stand as the top two state management choices within the framework community per npm download counts. Even though the following code snippets could be framed independent of implementation details, I saw that as an artificial reach for the sake of abstraction. I deliberately aimed for this article to deliver hands-on guidance. The broader local-versus-global logic still maps to whatever alternative solutions you prefer, but not every nuance in the examples carries over.
Before I get into why I prefer a local store, let me make one thing clear: I have nothing against global state as an idea, nor do I oppose NGRX global store as the most recognized and widely adopted implementation of it in the Angular world. I don't see the need to write feature.actions.ts, feature.reducer.ts, or feature.effects.ts as a heavy burden the way some developers do. On top of that, I genuinely appreciate the NGRX team’s long-standing contributions and their constant push to make the global store easier, for example, by adding createActionGroup.
Judging by what I've come across on this subject, many in the community appear to hold a wrong assumption: that these files exist
strictly for state management purposes. That belief leads those who favor a leaner file structure to view them as unnecessary.
However, there's more to it than just state handling or a one-way data flow. What actions truly deliver is indirection between the party firing an action and the area of your system responsible for dealing with it. Multiple parts of the app may respond to the same action in distinct ways. Certain runtime environments can also decide to skip actions that don't apply to them. That kind of flexibility gives you significant leverage to build a more adaptable codebase. I once built a solution for brokers and traders that functioned both as a web app and as a desktop app inside an Electron shell, and the conventional NGRX global store worked perfectly there, largely because of the indirection it gives you.
When indirection is a real requirement, going with a global store makes sense. Still, in the average project, I probably don't need that. What suits me better is a straightforward yet predictable way to expose public APIs for reading state, changing it, and causing side effects. I want state selectors, updaters, and effects grouped together so that related pieces stay in proximity. I also want state to be local, meaning it appears and disappears together with the component tree that consumes it. One common way to do this is by supplying it through the component's providers:
@Component({
providers: [FeatureStore],
template: `
@for(item of store.items$ | async; track item.id) {
{{...}}
}
<button (click)="store.removeItem(item.id)">Remove item</button>
<button (click)="store.loadItems()">Refresh</button>
`
})
export class FeatureComponent implements OnInit {
public store = inject(FeatureStore);
}
In my experience, this setup typically covers all state management needs without adding unnecessary complexity or tying component hierarchies to root-level providers. State bound to a component's lifecycle makes it easier to guarantee data freshness. Shifting local state to global state when needed is more straightforward than the reverse.
When the situation calls for it, this pattern also simplifies reusing logic across multiple components that rely on identical state or side-effect behavior. Each component receives its own store instance, tailored to its requirements. Configuration can vary—DI tokens are one option—but here's a basic configuration object supplied to the store provider factory:
export class ListDataStore extends ComponentStore<ListDataState> {
constructor(private config: Partial<ListDataState>) {
super({
...initialState,
...config
});
}
}
@Component({
providers: [{
provide: ListDataStore,
useFactory: () => new ListDataStore({
pageSize: DEFAULT_PAGE_SIZE
})
}],
})
@Component({
providers: [{
provide: ListDataStore,
useFactory: () => new ListDataStore({
sortBy: DEFAULT_SORT_SIZE,
sortDirection: DEFAULT_SORT_DIRECTION
})
}],
})
This means you can centralize all the custom logic for handling list data across your system, while still allowing each feature to tailor it to its own needs. It's a powerful yet often overlooked pattern for sharing logic in Angular. For more details, check out Make the most of Angular DI: private providers concept.
Instead of copying the component-store API docs here, I'll point you to the [official documentation](https://ngrx.io/guide/component-store/initialization). A few extra notes on this library stand out:
- Every state selector automatically includes
distinctUntilChangedandshareReplay, so there's no need to add them manually. - The library is compact, at 1.9kB minified + gzipped.
Rule 3: Limit component code by view-related logic only
When a component carries a significant amount of business logic, it’s better to shift state and side effects into component stores. Beyond ensuring consistency, this approach simplifies components and boosts maintainability in several key ways:
- Leverages DI to pass top-level state to child components within the same feature, avoiding input drilling.
- Simplifies reusing the same logic elsewhere when needed.
- Streamlines unit testing since you're only testing TypeScript classes rather than
TestBedsetups that mimic components with all their intricacies. - Establishes reactive state access as a default convention.
❌ Avoid
@Component({
...
})
export class FeatureComponent implements OnInit {
private cd = inject(ChandeDetectorRef);
private dataService = inject(DataService);
public data: Data;
public filteredData: Data;
public filterCriteria: FilterCriteria;
ngOnInit() {
this.loadData();
}
private loadData(): void {
this.dataService.getData().pipe(
tap((data) => {
this.data = newData;
this.applyFilter();
})
).subscribe();
}
private apllyFilter(): void {
this.filteredData = this.data.filter(item => item.name === this.filterCriteria);
this.cd.detectChanges();
}
}
<div *ngFor="let item of filteredData">
...
</div>
✅ Prefer
export class FeatureStore extends ComponentStore<FeatureState> {
private dataService = inject(DataService);
constructor() {
super({ data: [], filterCriteria: null});
}
public data$ = this.select(state => state.data);
public filterCriteria$ = this.select(state => state.filterCriteria);
public filteredData$ = this.select(
this.data$,
this.filterCriteria$,
(data, filterCriteria) => data.filter(item => item.name === filterCriteria)
);
public loadData = this.effect(trigger$ => trigger$.pipe(
switchMap(() => this.dataService.getData()),
tap(data => this.setData(data)),
));
}
@Component({
...
})
export class FeatureComponent {
public featureStore = inject(FeatureStore);
ngOnInit() {
this.featureStore.loadData();
}
}
<div *ngFor="let item of featureStore.filteredData$ | async">
...
</div>
The recommended path can occasionally demand a few extra lines of code, yet it is far simpler to keep up in the long run:
- Data dependencies are laid out in a declarative, reactive manner.
- Methods on the public API of the store remain accessible both to the feature container component and to each of its descendants.
- Observables work with high efficiency and do not leak memory. Effects get subscribed automatically the moment the store host component starts, and get unsubscribed on its tear-down.
Situations where state lives inside the component
A component store is a lightweight state management tool that works for both sophisticated and basic features, as well as for generic UI widgets packed with stateful behavior. Yet, there is no strict rule for when to apply it—practical judgment should steer the decision.
Tiny presentational components and basic UI controls
Think of a basic UI element such as this one:
@Component({
selector: 'app-slider',
...
@Input({required: true}) state: boolean;
@Input() infoTooltipText: string;
@Output() stateChange = new EventEmitter<boolean>();
//... formControl implementation
})
For compact presentational components and straightforward controls of this kind, introducing a dedicated state management class would be excessive. Keeping this logic within the component class itself is more appropriate.
Admittedly, this sample is somewhat contrived in its simplicity—your component may hold more state. The decisive signal for adopting a dedicated store is when you must transform that state (via operations like merging or filtering) and orchestrate side effects as part of the component’s encapsulated business logic.
Data aggregation is very specific to this component use case only
When a component store backs a component tree, the store class should hold logic that is already—or may eventually be—shared across nodes in that tree. However, we also want to avoid overloading the store with handlers for every conceivable scenario.
Consider a component that renders a peculiar loading indicator whose visibility depends on several conditions:
public secondResourceLoadingStep$ = combineLatest([
this.store.firstResourceHasLoaded$,
this.store.secondResourceHasLoaded$,
]).pipe(
([firstResourceLoaded, secondResourceLoaded]) => firstResourceLoaded && !!secondResourceLoaded
)
It might make more sense to keep this piece of data in the component class that renders the loading indicator rather than in the store class. The selectors firstResourceHasLoaded$ and secondResourceHasLoaded$ are reusable across various places, but this specific flag is solely tied to this component's template. Since it's also read-only, placing it close to the view that consumes it is a sensible choice.
Other use cases
When you're weighing whether business logic belongs in the store or the component class, a useful guideline is to reflect on a couple of key considerations:
- Is this same logic required in other branches of the component tree?
- Is the logic relevant only within this component's template context, like depending on its specific markup structure?
If the logic is tightly connected to the view, the component class is the right home for it.
Rule 4: Always separate stateful and stateless services
Stateful considerations and server API logic shouldn't be combined in a single class. Fetching or mutating data and managing app state are distinct responsibilities, and they ought to be kept apart at the code level too.
❌ Avoid
export class FeatureStore extends ComponentStore<FeatureState> {
private httpClient = inject(HttpClient);
//...
public loadData = this.effect((trigger$) =>
trigger$.pipe(
switchMap(() => this.httpClient.get<Data>(`${this.baseUrl}/data`)),
//...
),
);
}
✅ Prefer
export class FeatureStore extends ComponentStore<FeatureState> {
private dataService = inject(DataService);
//...
public loadData = this.effect((trigger$) =>
trigger$.pipe(
switchMap(() => this.dataService.getData()),
//...
),
);
}
export class DataService {
public getData(): Observable<Data> {
return this.httpClient.get<Data>(`${this.baseUrl}/data`);
}
}
Rule 5: Access state synchronously when it makes sense
RxJs is an excellent means of making applications reactive and describing data relationships in a declarative fashion, yet it is not a catch-all solution for every scenario.
Although RxJs excels with asynchronous data streams, there are times when reading a portion of state synchronously is all you need. Leveraging an observable in such situations merely adds unnecessary complexity to the code. That is why it is perfectly acceptable to reach for synchronous getter selectors within the component store, accessed through its private get method.
The key is to ensure reactivity works for you, rather than the reverse. If the code suggests you're forcing RxJs into a situation, fall back to synchronous logic:
❌ Don't
public doSomethingInUserContext(): void {
this.userStore.userId$.pipe(
take(1),
tap(userId => {
//... logic using userId here
}),
this.untilDestroyed()
).subscribe();
}
// or:
public async doSomethingInUserContext(): Promise<void> {
const userId = await this.userStore.userId$.pipe(
take(1)
).toPromise();
//... logic using userId here
}
✅ Prefer
public doSomethingInUserContext(): void {
const userId = this.userStore.userId; // where userId is a synchronous getter in the store class that uses component store `get` under the hood
}
Rule 6: Do not create "proxy methods" for no good reason
A frequent pattern in store examples—whether it's a global store or a component store, and even in official documentation—is to wrap a store method inside a component class method solely for template exposure. This technique makes sense when extra logic must run before the store takes over.
However, if no additional logic is required, these wrappers only add boilerplate, making the code wordier and complicating refactors without yielding any payoff. This is particularly true for state selectors in cases where the returned data requires no transformation.
❌ Avoid
@Component({
providers: [FeatureStore],
template: `
@for(item of items$ | async; track item.id) {
{{...}}
}
<button (click)="removeItem(item.id)">Remove item</button>
<button (click)="loadItems()">Refresh</button>
`
})
export class FeatureComponent implements OnInit {
private store = inject(FeatureStore);
private items$ = this.store.items$;
public removeItem(id: string): void {
this.store.removeItem(id);
}
public loadItems(): void {
this.store.loadItems();
}
}
✅ Prefer
@Component({
providers: [FeatureStore],
template: `
@for(item of store.items$ | async; track item.id) {
{{...}}
}
<button (click)="store.removeItem(item.id)">Remove item</button>
<button (click)="store.loadItems()">Refresh</button>
`
})
export class FeatureComponent implements OnInit {
public store = inject(FeatureStore);
}
There is a common convention of keeping services private and exposing their public interface to the component template through public methods. However, when working with component stores, I tend to view the component store provider as an integral part of the component class, one that facilitates logic composition. In this light, it falls to the store itself to decide which methods it exposes as public to consuming components and which remain private.
If a legitimate reason exists to define that method inside the component class, you are free to do so. Yet, avoid adding such proxy code simply to satisfy the "providers are always private" rule.
Rule 7: Prefer less verbose APIs whenever possible
In the majority of state update scenarios where no custom logic is required in the updater, patchState is the go-to option:
❌ Avoid
public setElement = this.updater((state: BlockSettingsState, element: BlockElement) => ({
...state,
element,
}));
✅ Prefer
private setElement(element: BlockElement): void {
this.patchState({element});
}
Rule 8: Remember about multicasting observables returned by selectors
Component store automatically applies shareReplay to each selector, but this guarantee disappears for downstream calculations once you chain a pipe onto a selector. In such cases, the optimal approach is to combine selectors together:
❌ Avoid
public combinedSelector$ = this.data$.pipe(
withLatestFrom(otherData$),
map(([data, otherData]) => expensiveComputations(data, otherData)) // will run for every subscriber in this case
✅ Prefer
public combinedSelector$ = this.select(
this.data$,
this.otherData$,
(data, otherData) => expensiveComputations(data, otherData))
);
When the combined selector approach isn't suitable, chain the shareReplay operator to the final part of the pipe:
.pipe(
//... other operators
shareReplay({refCount: true, bufferSize: 1}),
);
Rule 9: Use state management APIs, but do not limit yourself to it
Although consistency remains the objective, that doesn't force us to restrict the store class to library APIs exclusively. For the majority of scenarios, updaters, selectors, and effects cover the essentials, yet there are plenty of cases where it’s perfectly reasonable to encapsulate logic within straightforward private methods in the same component store class.
It's also common for parts of the state to reside in a reactive FormGroup, which comes with a robust set of built-in tools for form control manipulation and validation. In such situations, you can assign the FormGroup as a store class property and leverage its API within selectors and effects as required.
import { combineLatest } from 'rxjs';
import { debounceTime, shareReplay } from 'rxjs/operators';
this.filtersForm = new FormGroup({
name: new FormControl('', [Validators.required]),
//...
});
this.users$ = this.select((state) => state.users);
this.filteredUsers$ = combineLatest(this.users$, this.filtersForm.name.valueChanges.pipe(debounceTime(200))).pipe(
map(([users, filtersName]) => users.filter((user) => user.name.includes(filtersName))),
shareReplay({ refCount: true, bufferSize: 1 }),
);
Rule 10: Embrace pure functions and static methods
When a method never touches the instance properties of its class, turning it into an instance method is pointless. A pure function or a static method is the better fit. This choice pays off in a number of ways:
- When many copies of the component render at once—say, inside a list or a table—memory footprint drops because no per-instance method is allocated
- Unit testing becomes trivial: no component instantiation, no need to stub out DI providers, nothing extra to set up before you exercise the logic
❌ Avoid
private square(value: number): number {
return value * 2;
}
✅ Prefer
export const square = (value: number): number => {
return value * 2;
};
Whether you opt for pure functions placed in a separate file such as feature.fn.ts alongside the component file, or for static methods defined directly on the component class, is entirely your call. As a general guideline, favoring pure functions is typically better because they lend themselves more readily to testing and reuse. However, when the stateless logic is minimal and doesn't warrant its own file, static methods on the component class can serve as a perfectly adequate alternative.
Rule 11: Make sure you handle errors in effects
Since an effect operates as a long-lived observable, it's crucial to properly manage any errors that arise within the operator chain to ensure the effect remains active.
In typical real-world situations, an error thrown inside the operator chain shouldn't terminate the entire observable, which is what would occur in the absence of proper error handling. Employ the catchError operator, returning either of(<DATA PLACEHOLDER>) or EMPTY to preserve the observable's lifecycle.
❌ Avoid
public listenToSomeTrigger(): void {
this.someTrigger$.pipe(
switchMap(() => this.someService.getData().pipe(
map((data) => ...), // error can occur here, but no error handling is provided
)),
tap(() => ...),
).subscribe();
✅ Prefer
public listenToSomeTrigger(): void {
this.someTrigger$.pipe(
switchMap(() => this.someService.getData().pipe(
map((data) => ...),
catchError(error => {
...
return of([]);
}),
)),
tap(() => ...)
).subscribe();
}
⚠️ Make sure errors are caught in the inner observable chain (built with
switchMap) so that the outer observable doesn't terminate.
When dealing with operators that sit outside inner observables—such as those generated by switchMap or mergeMap—always surround the data-handling logic invoked by these operators with try/catch blocks, and return fallback placeholder values where appropriate.
Conclusion
These guidelines aim to keep your codebase consistent and simple to maintain. They've proven effective for me, but I'm open to any feedback that sparks a productive conversation. If you have questions or comments, feel free to
share them below, and I'll make sure to respond promptly.

