NGRX - from the beginning, part II, Redux
Redux is a Pub Sub implementation but adds some new artefacts to the pattern by adding concepts such as immutability and an idea that only certain artefacts should be able to change the state called reducers
This article is part of series:
- NGRX - from the beginning, part I, Pub-sub,
- NGRX - from the beginning, part II, we are here
- NGRX - from the beginning, part III, NGRX store, in progress
- NGRX - from the beginning, part IV, NGRX effects, in progress
- NGRX - from the beginning, part V, NGRX entity, in progress
Ok, we've learned about the Pub-Sub pattern in our last part so thereby we got to understand the underlying pattern to it all and when to use it. We even looked at an implementation. Now we will look at a specific version of the Pub-Sub pattern called Redux
In this article we will cover the following:
- The basic concepts, Redux consists of some basic concepts so let's list what they are and their responsibility
- Actions, let's go into what an Action is, when it's used and how to create one
- Reducers, reducers are functions that guard and change to state and they also lead to a change of that same state but in an orderly and pure way
- Store, the store is the main thing we interact with when we either want to know what the state is or we want to change it
- Naive implementation, let's look at how we can implement Redux so we really understand what is going on
The basic concepts
Ok, so there are some concepts we need to know about to be able to grasp Redux properly. We've mentioned them in the formers section but let's discuss them some more:
- Action, so the action is a message we send, it's an intention, something we want like adding a product to a list for example. An action has a type, which is verb representing our intention and it optionally has a payload, data that we need to carry our a change or use to query
- Reducer, a reducer is a function. The purpose of the reducer is to take an existing state and apply an action to the existing state. Reducers carry this out in an immutable way which means that we don't mutate the state but rather compute a new state based on the old state and the action
- Store, the store is like a container holding the state. The store is like the API of Redux, the main actor that you talk to when you want read state, change state or maybe subscribe to state changes
Ok, now we know a little more about the core concepts and what their role is. Let's look at each concept more in detail with code example, cause we understand better if we see code, right ? ;)
Actions
Actions are the way we express what we want to do. An action has one mandatory property type
and one optional property payload
. An Action is an object, so an example action can look like this:
const action = { type: 'INCREMENT' };
The above is a simpler action that doesn't have a payload, cause it's not needed, the intention or the type says clearly what needs doing, increment by one. However if you wanted to increase it by 2 using such an action you would need to describe that using a payload
, like so:
const action = { type: 'INCREMENT', payload: 2 };
A more common case for an action, with a payload would be adding a product, it would look like so:
const action = { type: '[Product] add', payload: { id: 1, name: 'movie' } };
Ok, so we understand actions a little better but how do we apply them to an existing state? For that we need to discuss reducers.
Reducers
Reducers are simple functions that carry out changes in an immutable way. Instead of mutating the state they are computing the state. Ok, sounds weird, let's look at a mutating example first and explain why that's bad:
let value = 0;
function add(val, val2) {
value += val + val2;
}
add(1,2) // value: 3
add(1,2) // value: 6
Above we can see that when we run the add()
function two times with the same input parameters we get different results. In a small contained example like this we can easily see why that is, we have variable value
declared and we can also see that the implementation of add()
function uses value as part of its calculation. In a more real scenario this might not be so easy to detect as the function might be many many rows long and contain a lot if complex things. This is bad because it isn't predictable and what we mean by that is that we can't easily see what the outcome of the function would be , give two parameters without first knowing the value of the variable value
. A more predictable version of the add()
method would be:
function add(lhs, rhs) {
return lhs + rhs;
}
add(1,2) // 3
add(1,2) // 3
As we can see from the above code execution, given the same value on the input parameters we get the same outcome, it's predictable.
Now remember this principle and let's apply that on a reducer whose job it is to handle operations on a list. Let's look at some code:
function reducer(state = [], action) {
switch(action.type) {
case '[Product] add':
return [...state, action.payload]
default:
return state;
}
}
Looking at the above code we see that instead of calling the push()
method on the list we use a spread operator and constructs an entirely new list based on our existing list state
and the new item being stored on action.payload
. Let's invoke this reducer()
function:
let state = reducer(
[],
{ type: '[Product] add', payload: { name: 'movie'} });
// [{ name: 'movie' }]
state = reducer(
state,
{ type: '[Product] add', payload: { name: 'book'} });
// [{ name: 'movie' }, { name: 'book' }]
What we can see from the above invocation is that we are able to keep on adding items to our list if we assign the result of reducer()
function invocation to the variable state
. Furthermore we also note how reducer()
does any addition to the list by computing:
old state + action = new state
This is an important principle in React and immutable functions how we change things so remember the above statement.
Store
Ok, we have understood so far that actions are the message that we send when we want to read data or change the data, in the state. So where is our state? It is stored in a store. Ok, so how do we communicate with the store? We do so by sending a message to it using the method dispatch()
. Let's try to start sketching on a store implementation:
class Store {
dispatch(action) {}
}
Ok, that wasn't much, lets see if we can improve this a bit. What do we know? We know that any state change should happen because we send an action to the dispatch()
method, but we also know that any state change is only allowed to happen if we let the action pass through a reducer. So that means that dispatch()
should call a reducer and pass in the action. Given how we used reducers in a previous section we have more of an idea now of how to do this. Let's use the reducer function we have already created as well:
function reducer(state = [], action) {
switch(action.type) {
case '[Product] add':
return [...state, action.payload]
default:
return state;
}
}
class Store {
constructor() {
this.state = [];
}
dispatch(action) {
this.state = reducer(this.state, action);
}
}
Ok, from the above code we can see that we instantiate our state
in the constructor and we can also see that we invoke the reducer()
function in our dispatch()
method and that we do this to compute a new state. Ok, let's take this for a spin:
const store = new Store();
store.dispatch({ type: '[Product] add', { name: 'movie' } });
// store.state = [{ name: 'movie' }]
Supporting more message types
Ok, that's all well and good but what if we want our state to support more things than a list? Let's think about this for a second, what do we want our state to look like in our app? Most likely we want it to contain a bunch of different properties, all with their own values, so it makes sense to put all these properties in an object, like so:
{
products: [],
language: 'en'
}
Ok, our current Store
implementation clearly doesn't support this, so we need to change it a bit, so let's change it to this:
function reducer(state = [], action) {
switch(action.type) {
case '[Product] add':
return [...state, action.payload]
default:
return state;
}
}
class Store {
constructor() {
this.state = {
products: []
};
}
dispatch(action) {
this.state = {
products : reducer(this.state.products, action)
};
}
}
Realising we want to store our state as an object we do the necessary changes in the constructor:
this.state = { products: [] }
This also means our dispatch()
method needs to change to:
dispatch(action) {
this.state = {
products : reducer(this.state.products, action)
};
}
This allows our reducer to only be applied to part of the state, namely this.state.products
.
Adding one more state property
At this point we realise that we need to support adding the property language
, so we add language to the initial state in the constructor like so:
this.state = {
products: [],
language: ''
};
Ok, so what do we do about the reducer()
, function then? Well at this point we realise we are missing a reducer that should be focused on setting a language, so let's start sketching on that:
function languageReducer(state = '', action) {
switch(action.type) {
case '[Language] load':
return action.payload;
default:
return state;
}
}
let state = languageReducer({
type: '[Language] load',
payload: 'en'
});
// state = 'en'
Now we have a reducer that is able to set a language. Let's go back to our Store implementation and add the necessary change to the dispatch()
method:
dispatch(action) {
return {
products : reducer(this.state.products, action),
language: languageReducer(this.state.language, action)
};
}
Let's also rename reducer()
to productsReducer()
and our full implementation should now look like this:
function productsReducer(state = [], action) {
switch(action.type) {
case '[Product] add':
return [...state, action.payload]
default:
return state;
}
}
function languageReducer(state = '', action) {
switch(action.type) {
case '[Language] load':
return action.payload;
default:
return state;
}
}
class Store {
constructor() {
this.state = {
products: []
};
}
dispatch(action) {
return {
products : productsReducer(this.state.products, action),
language: languageReducer(this.state.language, action)
};
}
}
Subscribing to changes
We have one important aspect left before our implementation is complete. The main things we need to support is, to be able to communicate changes are:
- Sending messages so that state changes
- Set up/tear down subscriptions
- Communicate a change to listeners
We have done the first one so lets support the second one. Let's implement a subscribe()
and unsubscribe
method:
subscribe(listener) {
this.listeners.push(listener);
}
unsubscribe(listener) {
this.listeners = this.listeners.filter(l => l !== listener);
}
2) and 3) are very tightly connected so let's revisit our dispatch()
method and let's make a change to it so it know looks like this:
dispatch(action) {
return {
products : reducer(this.state.products, action),
language: languageReducer(this.state.language, action)
};
this.listeners.forEach(l => l());
}
Slice of state
This is not a must have but sure is nice. Currently our state consists of the entire object but lets think for a second how this would be used. It's likely that the component using this will only be interested in parts of the state, so how do we do that? One way of solving that is to add a select()
method that has the ability to select the part of the state that it wants. It could look like so:
select(fn) {
return fn(this.state);
}
That doesn't look like much, does it actually work, well let's look at a use case:
select(state => state.products) // would give us the `products` but not the `language` one
select(state => state.language) // again, this only selects a slice of the state
Ok, our full code now reads:
// store.js
function productsReducer(state = [], action) {
switch (action.type) {
case '[Product] add':
return [...state, action.payload]
default:
return state;
}
}
function languageReducer(state = '', action) {
switch (action.type) {
case '[Language] load':
return action.payload;
default:
return state;
}
}
class Store {
constructor() {
this.listeners = [];
this.state = {
products: []
};
}
dispatch(action) {
this.state = {
products: productsReducer(this.state.products, action),
language: languageReducer(this.state.language, action)
};
this.listeners.forEach(l => l());
}
subscribe(listener) {
this.listeners.push(listener);
}
unsubscribe(listener) {
this.listeners = this.listeners.filter(l => l !== listener);
}
select(fn) {
return fn(this.state);
}
}
const store = new Store();
module.exports = store;
Using our implementation
Ok, we think we have an implementation that we can use, so let's apply it to some components:
// components.js
const store = require('./store');
class LanguageComponent {
constructor() {
store.subscribe(this.onChange.bind(this));
this.language = store.select(state => state.language);
}
onChange() {
this.language = store.select(state => state.language);
}
}
class Component {
changeLanguage(newLanguage) {
store.dispatch({
type: '[Language] load',
payload: 'en'
});
}
}
class ProductsComponent {
constructor() {
store.subscribe(this.onChange.bind(this));
this.products = store.select(state => state.products);
}
add(product) {
store.dispatch({
type: '[Product] add',
payload: product
});
}
onChange() {
this.products = store.select(state => state.products);
}
}
module.exports = {
LanguageComponent,
Component,
ProductsComponent
}
Ok, let's try to invoke the above:
const {
LanguageComponent,
ProductsComponent,
Component
} = require('./components');
const store = require('./store');
const component = new Component();
const languageComponent = new LanguageComponent();
const productsComponent = new ProductsComponent();
component.changeLanguage('en');
console.log('lang comp', languageComponent.language);
productsComponent.add({
name: 'movie'
});
console.log('products comp', productsComponent.products);
console.log('store products', store.state.products);
Summary
Ok, we managed to explain all the core concepts and even managed to create a vanilla implementation of Redux and even show how we would use it with components. You can use this solution on any framework or library. Hopefully that point has come across that Redux is Pub-Sub but that state is something we care deeply for and we care that the state is changed in an orderly and pure way.
In our next part we will look into NGRx itself and how to use the Store library.