NgRx is a library for Angular which manages a global data store for your app. This article covers some of the core concepts of NgRx:
- Actions are like events
- Reducers write data to the state
- Effects do API calls and map actions to other actions
- Selectors are for reading data from the state
An Angular app using NgRx will usually have a store
directory which should be organised into “sub-stores” for each major data type in the app.
Sub-stores are referred to as store Features. Each Feature like "Users" or "Projects" will have its own directory, and its own Reducer, Actions, Effects and Selectors.
This is a concept guide, not a getting started guide (sorry!). Only small snippets of code are included as examples.
Actions are like events
Actions have unique names, and they are dispatched with some data attached — just like an event.
Actions are defined in groups like this:
const ProjectActions = createActionGroup({
source: 'Project API',
events: {
// defining events with payload using the `props` function
'Load Project': props<{ projectId: number }>(),
'Load Project Success': props<{ project: Project }>(),
'Load Project Failure': props<{ error: Error }>(),
},
});
NgRx works some magic so that when you use each Action in your code, you refer to it by the type you gave it (the string name / key), but as a camel-cased property of the Action Group.
So from the example above, the Action 'Load Project'
would be referred to as ProjectActions.loadProject()
.
An Action is created and dispatched like this:
const loadProject = ProjectActions.loadProject({ projectId: 123 });
store.dispatch(loadProject);
Actions are just simple objects, so a loadProject
action is really just something like this inside:
{
type: '[Project API] Load Project',
projectId: 123
}
The type has to be unique across all actions in your app, so it’s common to add a namespaced source like [Project API]
to make debugging easier via Redux Dev Tools and to avoid naming conflicts. NgRx does this automatically when you use the createActionGroup
function to define your Actions.
In NgRx, both Reducers and Effects can listen for specific Actions.
This means an Action can trigger a Reducer (to change the app state) or trigger an Effect (to go load some data) or it could do both.
Read the official docs for Actions.
Reducers update the state
A Reducer is a collection of functions that each update a small part of the global app state.
Each Reducer function takes in an Action and the current app state, merges the Action’s data into the app state, and returns the new updated app state.
Reducers write data to the global app state, and Selectors read data from the global app state.
A Reducer is usually created like this:
export const projectsReducer = createReducer(
initialState,
on(ProjectActions.loadProject, (state) => ({
...state,
loading: true
})),
on(ProjectActions.loadProjectSuccess, (state, action) => ({
...state,
loading: false,
projects: action.projects
})),
on(ProjectActions.loadProjectFailure, (state, action) => ({
...state,
loading: false,
errors: [
...state.errors,
action.error
]
})),
)
The initialState
is defined by you, it’s whatever app state you want to start from when the app is first loaded. Each on()
function updates the app state when a particular Action happens.
Not every Action needs a matching Reducer, it’s okay to have some Actions which don’t update the app state. A Reducer doesn’t have to process every Action, it can just ignore any Actions it doesn’t care about.
Reducers should ideally do little to no logic. They should rely on being provided clean Actions where the data is already in the correct format.
The state should be immutable, which is why you’ll see Reducers using the ...
spread operator to return a copy of the state including the new data, rather than just updating some specific properties.
If an Action’s data needs cleaning, the logic for cleaning should be in a Service and the Action should be processed by an Effect to call that Service.
Read the official docs for Reducers.
Effects map actions to other actions
An Effect usually watches for an Action in your app, executes a function (usually in a Service), and then dispatches another follow-up Action.
An Effect is written as an Observable.
Most Effects look something like this:
loadProject$ = createEffect(() =>
this.actions$.pipe(
ofType(ProjectActions.loadProject),
exhaustMap((action) =>
this.projectService.getOne(action.projectId).pipe(
map((project) => ProjectActions.loadProjectSuccess({ project }),
catchError((error) => of(ProjectActions.loadProjectFailure({ error })))
)
)
)
);
To translate the Effect above: When an action ofType loadProject
happens, use exhaustMap
to call projectService.getOne(projectId)
and then map the result to a new Action — either loadProjectSuccess
or loadProjectFailure
.
The Action going in is a loadProject
.
The Action coming out will be one of loadProjectSuccess
or loadProjectFailure
.
Sometimes, an Effect may not do an API call, but instead just process an action into one or more other actions, to trigger one or more other Effects.
Very rarely, an Effect may call a function where the result is not important, and so it doesn’t dispatch any kind of success or failure event:
export const loadProjectFailure = createEffect(() =>
this.actions$.pipe(
ofType(ProjectActions.loadProjectFailure),
tap(({ error }) => console.error('Project load failed:', error))
),
{ dispatch: false }
);
The example above shows adding { dispatch: false }
to make this Effect a “dead end” which will not emit an Action.
Read the official docs for Effects.
Selectors are for getting data
A Selector pulls data out of the global app state. It reads from the big JSON data blob that is maintained by the apps’ Reducers.
Usually, each store Feature defines a selector for the entire Feature state — all the data related to that Feature, like this:
const projectsFeature = createFeatureSelector<ProjectsState>('projects');
That’s because Selectors are composable, so you can make a selector based on another selector. Once you have a selector for a whole Feature, you can drill in deeper to select more specific data.
This is how you select a specific piece of data from a Feature:
export const getAllProjects = createSelector(
projectsFeature,
(state: ProjectsState) => state.projects
);
Selectors are Observables, so they emit new values over time as the underlying app state changes.
Selectors can be used from anywhere, but they are most commonly seen in Component code, where they provide data to be used in the template.
A Selector is used in a component’s class like this:
public allProjects$ = this.store.select(getAllProjects);
A Selector’s value can be displayed as JSON-formatted text in a template like this:
<pre>{{ allProjects$ | async | json }}</pre>
Or the value can be passed as an input like this:
<app-projects-list [projects]="$allProjects | async"></app-projects-list>
Read the official docs for Selectors.
There’s a bunch of different “personal preference” styles for writing NgRx apps, but the core concepts remain the same.
Data is handled in a continuous way, where values change dynamically over time based on changes to the app state behind the scenes.
- Actions are like events, they carry data. They can be used to trigger an Effect, or deliver data to a Reducer.
- Reducers write data to the state. They run when an Action happens that they’re configured to capture.
- Effects handle async functionality to map one Action into another. They emit Actions that may contain data for the Reducer to add to the state, or may trigger other Effects.
- Selectors are for reading data from the state, and their values change dynamically as the underlying app state evolves.
Top comments (0)