NgRx Effects in Practice
Effects give you a clean mechanism for managing side effects across an application. The typical use case involves HTTP requests — you set up an effect to talk to a service and dispatch the result back into the store. But they are capable of far more than that. Honestly, that was the only way I had used them until I came across this write-up on handling Angular Material dialog flow with effects.
The notion of shifting dialog-related code out of components and into effects caught my attention. I began to think of the dialog lifecycle as a sequence of events, with effects coordinating those events directly. This approach keeps components lean: they read state through selectors and emit actions in response to user interaction. When components stay straightforward and focused, they become much simpler to test and adapt when new requirements arrive. That was my main incentive for going down this route, though I wanted to first double-check the usual best practices to avoid creating something that could be seen as an anti-pattern.
Guidelines for Working With Effects
These points are not a complete checklist, but a handful of useful rules I picked up from Mike Ryan of the NgRx Core Team, as shared on this episode of The Angular Show.
HTTP services should only be called from within effects
That principle is easy to grasp and makes complete sense.
Choose the proper higher-order mapping operator; when in doubt, go with
concatMap
Solid advice. Still, if your effect isn't returning inner observables, you might not need one at all — just be mindful of backpressure.
When the effect needs store data, use
concatLatestFrom
This one matters quite a bit. The concatLatestFrom operator subscribes to store selectors only when the relevant action is being handled, making it lazy. With withLatestFrom, the selector stays subscribed and keeps computing even when you're not in that part of the app. Swapping one for the other is nearly painless and delivers an easy win in performance.
Split larger effects into several smaller ones that all respond to the same action
The action stream that effects subscribe to is multicast, and ofType lets each effect pick out the actions it cares about. So it makes sense to write small effects that handle one side effect each.
Effects communicate with each other through actions
That's more of a useful insight than a rule. NgRx leans heavily on indirection, with actions pushing information through that layer. This is also how effects talk to reducers — though reducers only listen, they never respond.
Typical Material Dialog Flow
With a clearer picture of how effects work, let's look at the usual flow of a Material dialog.
To use Material Dialogs, the dialog service has to be injected into the host component. That service gives you the methods needed to control dialogs, such as opening one.
//app.component.ts
@Component({
template: `...`
})
export class AppComponent {
constructor(private dialog: MatDialog) {}
//click handler when we wanna open the dialog
openDialog(){
const configData = {} //whatever we wanna give our dialog
const dialogRef = this.dialog.open(DialogComponent,configData)
dialogRef.afterClosed().subscribe(data => {
this.doSomethingWithData(data)
})
}
}
//dialog-component.component.ts
@Component({
template: `...`
})
export class DialogComponent {
constructor(
public dialogRef: MatDialogRef<DialogOverviewExampleDialog>,
@Inject(MAT_DIALOG_DATA) public data: DialogData)
) {}
save(data){
this.dialogRef.close(data)
}
}
This example is admittedly simple, but it still shows the typical dialog lifecycle. The Mat Dialog service is injected, then a click handler is set up to open the dialog with the data the dialog component needs. Inside that component, a reference to the opened dialog is injected, along with an InjectionToken that carries the data passed in. When the user confirms, the dialog is closed and returns the result data.
Back in the host component, calling open returns a reference to the dialog. That reference exposes an afterClosed observable that emits the data supplied when the dialog's close method was called. From there, that data usually triggers further work, often an HTTP request that sends it to the server.
Even in this straightforward example, the openDialog method is doing quite a lot. It's not just opening the dialog — it's managing the whole lifecycle, collecting the result, and then handling whatever needs to happen next. That makes testing both the component and the method more involved than necessary.
Treating the Lifecycle as a Sequence of Events Handled by Effects
Now that the groundwork is laid, we can get to the heart of this article. The dialog flow breaks down into: open, interaction (save or cancel), then close. Let's map out these lifecycle stages with the corresponding actions.
//dialog.actions.ts
const dialogOpened = createAction(
'[Home Page] Dialog Opened',
props<{component:unknown, data:unknown}>()
)
const dialogSaved = createAction(
'[Home Page] Dialog Saved',
props<{data:DataToSave}>()
)
const dialogClosed = createAction(
'[Home Page] Dialog Closed',
props<{data:DataToClose}>()
)
Avoid using any when you can help it. That said, typing components can be tricky, and the shape of the data may differ from one setup to the next.
Now we set up the effects that will listen for those actions.
//dialog.effects.ts
@Injectable()
export class DialogEffects {
constructor(private actions$: Actions){}
saveDataSuccess$ = createEffect(() => this.actions$.pipe(
ofType(DataActions.SaveDataSuccess),
map(response => DialogActions.dialogClosed(response))
))
dialogOpened$ = createEffect(() => this.actions$.pipe(
ofType(DialogActions.dialogOpened),
tap(payload => {
this.dialogRef.open(payload.component,payload.data)
})
),{dispatch:false})
dialogSaved$ = createEffect(() => this.actions$.pipe(
ofType(DialogActions.dialogSaved),
map(payload => DataActions.SaveData(payload))
))
dialogClosed$ = createEffect(() => this.actions$.pipe(
ofType(DialogActions.dialogClosed),
map(payload => {
this.dialogRef.closeAll();
return snackBarActions.savedSuccessfully(payload)
})
))
}
There are two key details to notice. First, the dialogOpened$ effect uses {dispatch: false}. This tells NgRx that this particular effect won't emit any new actions. Without it, you'd end up in an endless loop, eventually causing the browser to crash. Second, there's an effect watching for the Success action that fires after a successful HTTP request. It's there to dispatch the dialogClose action, since we don't want the dialog to close until the data is safely saved — and not at all if something fails.
In the components themselves, it all comes down to dispatching the right actions.
//app.component.ts
@Component({
template: `...`
})
export class AppComponent {
constructor() {}
//click handler when we wanna open the dialog
openDialog(){
this.store.dispatch(DialogActions.dialogOpened({component,data}))
}
}
//dialog-component.component.ts
@Component({
template: `...`
})
export class DialogComponent {
constructor(@Inject(MAT_DIALOG_DATA) public data: DialogData) {}
save(data){
this.store.dispatch(DialogActions.dialogSaved({data}))
}
}
Component tests get simpler
With a portion of the logic moved from the component into the effects layer, writing tests has become noticeably more straightforward. We’ve managed to strip out a number of dependencies from the component, which means those pieces no longer need to be mocked in our test suite. To cover the extracted methods, we only have to verify that the expected dispatch action is triggered with the correct payload.
describe("DialogComponent", () => {
let component: DialogComponent;
let fixture: ComponentFixture<DialogComponent>;
let store: MockStore;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [DialogComponent],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
provideMockStore(initialState),
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DialogComponent);
store = TestBed.inject(MockStore);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should dispatch save action with form data", () => {
const storeSpy = spyOn(store,"dispatch")
component.save(formData)
expect(storeSpy).toHaveBeenCalledWith(DialogActions.dialogSaved(expectedData))
})
})
Admittedly, this example is somewhat fabricated, but the goal is to show how shifting the work out of the component reduces the testing burden. A leaner component lowers the barrier to getting started with tests. For the method in question, a simple assertion that the dispatch call was made with the right action might be all that’s needed — anything further belongs in the effects test. A possible test for the effect is shown below.
describe("DialogEffects", () => {
let actions$ = new Observable<Action>();
TestBed.configureTestingModule({
providers: [provideMockActions(() => actions$)],
});
describe("dialogSaved$",() => {
it("should dispatch action to save data",(done) => {
actions$ = of(DialogActions.dialogSaved({data}))
dialogSaved$.subscribe(result => {
expect(result).toBe(DataActions.saveData)
})
})
})
})
Wrap-up
To wrap things up, like Tim, I find managing the lifecycle of a material dialog much smoother when it’s moved into the effects pattern. The composability that effects provide makes it easy to assemble more complex interactions. This approach shifts logic into the effects layer, which keeps components light, focused, and a lot easier to test.


