Chapter 6: Reducers
#
How do we modify the State?So far, we have learned how to create Actions
, the building blocks of NgRx, that signal the Store
about what happened in the application and what needs to be done. But Actions
are just objects, that don't hold any functionality; they by themselves cannot make any State
changes. To make those changes actually happen, we have to use a Reducer
, next of the core most important concepts of NgRx.
#
What are Reducers?Reducers are pure functions that receive two arguments, the current State
of the application, an Action
object, calculate the new state based on the Action
that happened, and return that new State
. Usually calculating involves determining which Action
happened (sometimes via switch-case
statements, but NgRx provides utilities that reduce boilerplate), copying the previous state into a new object while modifying the relevant properties of the State
, and returning the new State
object. Every time an Action
is dispatched (we will learn more about dispatching in the next chapters, for now "dispatching" an Action
means the Action
has happened), NgRx will call the Reducer
function, providing the old State
and the Action
object as arguments, get the returned new State
and immediately notify all of our components about the change (how neat is that!). Essentially, Reducers
are the central place where State
changes happen.
#
What are pure functions?In the previous paragraph, we mentioned a concept of "pure functions", a core principle of functional programming. If you are familiar with the concept of a pure function
, or with functional programming in general, you can skip this paragraph. If not, let's explore what a pure function
is. A pure function
is a function that:
- Receives data only using its arguments
- Never accesses global variables
- Provided the same arguments, always returns the same result
- Does not have side effects. By side effects we mean every impact a function can have on the external environment, like modifying a global variable,
console.log
-ing something, sending HTTP requests, accessinglocalStorage
orcookies
and so on.
Basically, a pure function
is a function that only receives arguments, calculates a result (that is always the same for the same arguments), and returns that result.
Example of a pure function
:
function add(a: number, b: number) { return a + b;}
This function will always return the same result for the same numbers, and has zero impact on the external environment.
Example of an impure function
:
function sendRequest(url: string) { return fetch(url).then((response) => response.json());}
This function makes an HTTP request, which is a side effect. On top of that, even if the URL stays the same, there is no guarantee that the remote server will always return the same data; thus, this function is impure.
Pure functions are easy to understand, test and debug. Thus, they are a very important concept in functional programming, and, by extension, NgRx. You can read more about pure functions
here and here.
#
Understanding our StateBefore we can write a Reducer
, we first must understand the shape of our State
. What data it holds, what are the property names and so on. So far our state is simple: we have an array of Category
items, and that is it.
In the state
folder that we have created, create a file named state.ts
and put the following code in it:
export interface Category { name: string;}
export interface AppState { categories: Category[];}
export const initialState: AppState = { categories: [],};
In this file, we have defined what a Category
is, defined how our application State
looks via the AppState
interface, and defined the initialState
(the state that the app has before any Action
has happened). Now we are set to create our first Reducer
function!
#
Writing ReducersIn the same state
folder, create another file named reducer.ts
, and put the following code in it:
import { Action } from "@ngrx/store";
import { AppState } from "./state";
export function reducer(state: AppState, action: Action) { switch (action.type) { case "[Category List] Add Category": return { ...state, categories: [...state.categories, action.payload] }; default: return state; }}
NOTE: Beware of the payload attribute of the Action, which only fits the "old" way to create actions. Next example will use the props and call directly the properties added to the Action.
Now let's deconstruct this example. First of all, as you see, the reducer
function is a pure function. Next, it differentiates between Actions
using a switch
statement and the Action
types. On each different Action
, our Reducer
will return a slightly different version of a State
to match that Action
's intent. As we have only one Action
for now, we wrote only one case
statement, and if the Action
is not identified, we return the state
without modifying it. As you can see, the practice is to copy the state, and return a new object. Never modify the previous state
object, always return a new one.
What will happen is when we dispatch the addCategory
Action
, the reducer
function will be called, the new State
(with the added category object in the categories
array) will be returned, and then NgRx will propagate that change to all of our components.
Reducer
is a great way to centralize State
changes. if we have any problems with how our State
has been changed, we need only to look inside the reducer
to find out where the bug is. It is a pure function
, so it also easy to test, and understand.
#
Using NgRx built-in functions to reduce boilerplateWriting long switch
statements can be tedious; those might be even harder to read; a Reducer
in a large enterprise project might as well have hundreds of case
statements. Thus, the NgRx team has created special functions to help us generate the Reducer
function. Let's rewrite our code to use those functions:
import { Action, createReducer, on } from "@ngrx/store";
import * as actions from "./actions";import { AppState, initialState } from "./state";
const _reducer = createReducer( initialState, on(actions.addCategory, (state, {category}) => ({ ...state, categories: [...state.categories, category], })));
export function reducer(state: AppState, action: Action) { return _reducer(state, action);}
Let's understand what goes on here. First of all, we have the createReducer
function, which does exactly that - creates the resulting Reducer
function. It receives the initial state of the application, and then an arbitrary amount of handlers for each action (we provided only one, but we can (and will!) have lots of Actions
, and thus. lots of on
function calls). The on
function receives an Action
as the first argument, and a callback function as a second one, which acts like a mini-Reducer
for only that specific Action
. Thus, the need to write huge switch
statements is eliminated. Note that the callback signature is (state, {category}) in which we destruct the action passed in the second argument in order to only get the props. Now let's understand two specific parts of this code:
- Why did we create a
_reducer
function only to call it in thereducer
function? The thing is, in the next chapters we are going to register thereducer
function in ourAppModule
, and because of how the Angular build process works, it does not allow including anonymous functions like the one returned bycreateReducer
, only declared named functions. Thus we wrote a conventional function and called our_reducer
from it. Always createReducers
like this. - _Why didn't we
import
theAction
we wanted by name, but rather didimport * as actions from './actions';
? Because we can easily have dozens ofActions
that are handled in a singleReducer
function, we would like to import them as a single object to avoid too cluttered imports.
#
HomeworkOf course, homework isn't going anywhere. Remember the Actions
we created in the previous homework? we are going to use them.
- In the same
reducer
, create a handler for deleting aCategory
(consider the name of theCategory
unique). - Write a handler that deletes all categories from the
Store
.
Now we not only know how to create Actions
modify the State
with them in the Reducer
function, but also how to avoid too much boilerplate code. Now let's learn how to actually use the State
data in our components.
Exercise 1 solution
const _reducer = createReducer( initialState, // other handlers on(actions.deleteCategory, (state, {name}) => ({ ...state, categories: state.categories.filter( (cat) => cat.name !== name ), // filter out the deleted category })));
Exercise 2 solution
const _reducer = createReducer( initialState, // other handlers on(actions.deleteAllCategories, (state) => ({ ...state, categories: [] })) // just assign a new empty array for categories);