TL;DR
- Reduce redundant API calls by centralizing loading logic in a shared helper.
- Always expose a domain model (class instance) instead of raw state slices.
- Cache with
shareReplay
so multiple subscribers share one request. - Dispatch only plain-object payloads—no class instances in actions.
- Inspired by our experience in the Summon Worlds AI world-building app.
0. The Backstory 🏗️
First things first - a story. In September of last year, we took over an AI World Building App called Summon Worlds. The app was a cool idea, had an great community, and scratched that gaming itch that all developers have.
So after we took it on, we rebuilt the (very hacky) flutter prototype in Ionic + Angular, and we decided to use NGRX to help with performance and storage calls. Since we were in a hurry, that was about all the thought we put into the pattern in which we were implementing NGRX and we thought it would be an easy process.
It was not.
So, fast forward till today - there were a ton of architecture and pattern decisions that I wish we did differently, and I'd like to share some of them with you so that hopefully you do not make the same mistakes as we did.
This particular article is focused on an "accidental" antipattern of NGRX + Clone Deep that caused major issues. If you like it let me know, and I'll write a couple more about some other anti-patterns to avoid!
1. How We Discovered The Antipattern 🏗️
The State was managed via NgRx and persisted to Firebase. One morning we noticed our billing spike—and Cloud Functions logs revealed that loading our simple “feed” screen fired 42 loadEntity
calls every time. On slower networks, users waited ages. We knew something had to change.
2. Recognizing the Anti-Pattern
2.1 The Over-eager Helper
A common helper service looked like this:
@Injectable({ providedIn: 'root' }) export class GenericHelperService { constructor(private store: Store<AppState>) {} // Raw state slice used everywhere getEntityState$(): Observable<EntityState> { return this.store.select(selectEntityState); } // Lightweight shortcut mapping to a class getEntity$(): Observable<EntityModel> { return this.store.select(selectEntityData).pipe( map(data => new EntityModel(data)) ); } // Dispatching class instances directly updateEntity(model: EntityModel) { this.store.dispatch(updateEntity({ payload: model })); } }
Every component calling getEntity$()
would dispatch loadEntity
again and again, re-instantiate the model, and violate NgRx’s serialization rules.
3. Core Principles of the Fix
- Raw slice only for edge cases.
- Instantiate your domain model exactly once in a shared helper.
- Dispatch only plain JSON payloads—no class instances in actions.
These principles rescued our performance in Summon Worlds and will help any NgRx project.
4. A Generic Refactor Recipe
4.1 Strengthen the Helper
@Injectable({ providedIn: 'root' }) export class GenericHelperService { constructor(private store: Store<AppState>) {} /** Raw slice — cache and reuse sparingly */ getEntityState$(): Observable<EntityState> { return this.store.select(selectEntityState).pipe( shareReplay(1) ); } /** Central loader + model emitter */ getEntity$(id: string): Observable<EntityModel> { // Trigger load exactly once per id this.store.dispatch(loadEntity({ id })); return combineLatest([ this.store.select(selectEntityById(id)), this.store.select(selectEntityLoading(id)) ]).pipe( filter(([data, loading]) => !!data && !loading), map(([data]) => new EntityModel(data)), shareReplay({ bufferSize: 1, refCount: true }) ); } /** Mutations accept model, dispatch plain data */ saveChanges(model: EntityModel): void { const payload = model.toPlainObject(); this.store.dispatch(updateEntity({ payload })); } }
-
shareReplay(1)
shares one subscription, caching the latest value. -
combineLatest + filter
waits until data is ready before emitting. -
toPlainObject()
strips methods, leaving a serializable payload.
4.2 Use the Async Pipe Everywhere
Before (Summon Worlds style):
ngOnInit() { this.sub = this.helper.getEntity$(id) .subscribe(model => this.value = model.compute()); } ngOnDestroy() { this.sub.unsubscribe(); }
After:
value$ = this.helper.getEntity$(id).pipe( map(model => model.compute()) );
<div>{{ value$ | async }}</div>
No manual subscriptions, no memory leaks, no boilerplate.
4.3 Remove Deep-Clone Workarounds
Swap out:
const safeData = cloneDeep(rawData)!;
For:
computed$ = this.helper.getEntity$(id).pipe( map(model => model.optionalProp ?? defaultValue) );
TypeScript strict checks catch missing properties, eliminating the cloneDeep
band-aid.
4.4 Centralize Error Handling
Instead of catching errors in every component:
this.helper.getEntity$(id).pipe( catchError(err => { console.error(err); return of(null); }) );
Handle failures in an effect:
loadEntityFailure$ = createEffect(() => this.actions$.pipe( ofType(loadEntityFailure), tap(({ error }) => this.toast.error(`Load failed: ${error}`)) ), { dispatch: false } );
Your components only worry about rendering the happy path.
5. Migration Checklist
- Helper overhaul
- Add
shareReplay
on raw-slice methods. -
Implement
getEntity$()
with load dispatch,combineLatest
,filter
, mapping, andshareReplay
.- Component cleanup
-
Replace manual subscriptions with async-pipe streams.
- Payload serialization
Add
.toPlainObject()
to your models.-
Dispatch only JSON in actions.
- Remove
cloneDeep
- Remove
-
Delete all deep-clone code.
- Centralize error flow
-
Catch errors in effects only.
- Enable runtime checks
StoreModule.forRoot(reducers, { runtimeChecks: { strictStateImmutability: true, strictActionImmutability: true, } })
6. Measuring Success
Metric | Before | After | Improvement |
---|---|---|---|
Backend calls per view | 42 | 4 | −90% |
Memory on initial render (MB) | 28 | 18 | −36% |
Time to first paint (s) | 4.2 | 2.7 | −1.5 |
(These are some of the actual numbers we achieved, fyi)
7. FAQs
Q: Why not dispatch class instances?
A: They break DevTools serialization, effect replay, and cross-tab sync. POJOs are the safest contract.
Q: Aren’t selectors memoized?
A: Yes—but extra loadEntity
dispatches before data arrives bypass selector caching. shareReplay
fixes that gap.
8. Generic Snippets to Copy
Model Skeleton
export class EntityModel { constructor(private data: EntityData) {} get id(): string { return this.data.id; } compute(): number { /* domain logic */ return 0; } toPlainObject(): EntityData { return { ...this.data }; } }
Helper Skeleton
@Injectable({ providedIn: 'root' }) export class GenericHelperService { constructor(private store: Store<AppState>) {} getEntity$(id: string): Observable<EntityModel> { this.store.dispatch(loadEntity({ id })); return combineLatest([ this.store.select(selectEntityById(id)), this.store.select(selectEntityLoading(id)) ]).pipe( filter(([data, loading]) => !!data && !loading), map(([data]) => new EntityModel(data)), shareReplay({ bufferSize: 1, refCount: true }) ); } }
9. Next Steps
- Measure your own before/after metrics.
- Refactor one entity at a time.
- Share this guide and (of course) check out Summon Worlds if you are into AI world-building.
Happy refactoring—and may your selectors be ever memoized!
Top comments (0)