Now that we have Redux setup we will continue with this topic by adding Redux actions that will be responsible for adding and removing favourites from our state in the Redux store. This means that when we add a favourite on the map the favourites list will automatically update.
Let's begin by creating a directory in the application where all of our actions will be stored.
mkdir app/js/actions
Next we will create an index.js
file in this directory that will be
responsible for exporting all our actions from our various actions files.
Create the index.js
file and export everything from the points.js
file
(which we will create in a moment) using the *
wildcard.
// app/js/actions/index.js
export * from './points';
Next we will create the points.js
file that will contain our actions related
to points. Let's start by creating an action called addFavourite
which will
signal to our application that the user set a point on the map as a favourite,
allowing the application to update accordingly.
Redux actions are simply functions that return an object. The object must have a
key called type
with a unique value identifying the action. It is standard to
name the type in uppercase.
The action object can also contain other information. For example in this case
we will store an entry called index
in the action which will be the array
index of the point that should be marked as a favourite.
It is also a common standard to put additional information in a sub-object
called payload
to differentiate it from the type
.
Go ahead and add the points.js
file with the addFavourite
action.
// app/js/actions/points.js
export const addFavourite = index => {
return {
type: 'FAVOURITE_ADDED',
payload: {
index: index
}
};
};
We are also going to need an action to tell the application that a favourite was
removed. Let's add a removeFavourite
action. It will look similar to our "add"
action as it also needs the array index of the point to update.
// app/js/actions/points.js
export const addFavourite = index => {
return {
type: 'FAVOURITE_ADDED',
payload: {
index: index
}
};
};
+ export const removeFavourite = index => {
+ return {
+ type: 'FAVOURITE_REMOVED',
+ payload: {
+ index: index
+ }
+ };
+ };
As was mentioned earlier, Redux action types have to be unique in case they
override each other. As it would be possible to have actions with duplicate
types as your application grows if you just use uppercase strings for the action
type, it is a common practise to introduce a "constants" file that defines all
shared constants for your Redux actions (as well as for other constants you need
to share). Let's create a constants.js
file in the app/js/
directory that
will define our action types. All constants in this file should be exported.
// app/js/constants.js
export const FAVOURITE_ADDED = 'FAVOURITE_ADDED';
export const FAVOURITE_REMOVED = 'FAVOURITE_REMOVED';
We now need to import these constants as the action types into our points actions.
// app/js/actions/points.js
+ import { FAVOURITE_ADDED, FAVOURITE_REMOVED } from '../constants';
export const addFavourite = index => {
return {
- type: 'FAVOURITE_ADDED',
+ type: FAVOURITE_ADDED,
payload: {
index: index
}
};
};
export const removeFavourite = index => {
return {
- type: 'FAVOURITE_REMOVED',
+ type: FAVOURITE_REMOVED,
payload: {
index: index
}
};
};
When an action is "dispatched" Redux sends the action to all reducers that were
passed to the store after being registered via combineReducers
. This means
that multiple reducers can listen to one action and update the state.
For this reason it is good to design your actions and reducers in an idiomatic
manner 1. This means that you should design your actions to describe exactly
what the user is doing and allow your reducers to represent your data correctly
instead of simply mapping your actions to reducers. For example an application
may dispatch a LOGOUT
action and multiple reducers will listen to this and
update the state. The "account" reducer could remove the logged in user ID, the
"basket" reducer would clear all items from the cart, a "navigation" reducer
would update the links shown on the navigation bar to the user.
This is preferable than having to dispatch 3 action REMOVE_LOGGED_IN_USER_ID
,
CLEAR_CART
and UPDATE_NAVIGATION_LINKS
when the user clicks the logout
button.
Now that our points actions are defined we need to wire them up to our connected
components. We will update the Pointer
component so that instead of simply
updating it's internal state
with the information weather a pointer is a
favourite or not it will dispatch one of our Redux actions, triggering the
points reducer to update the application state.
We dispatch an action by calling the dispatch
method provided by Redux and
passing the returned object from our action to it. We cannot however simply
import the dispatch
method from the Redux package and use it directly. Only
the Redux store has the dispatch
method available and in a React application
we do not have direct access to the store. Instead we are given the dispatch
method as a prop to all connected components.
We could call dispatch
directly from the props, for example:
// Import the action to our component
import { myAction } from './actions';
// Inside of a connected component, `dispatch` is available as a prop.
this.props.dispatch(myAction());
However instead of using dispatch
directly via the props we can map the
dispatch to actions when we connect the component. In the last step we saw that
you can pass a mapStateToProps
function as the first argument to the connect
method. The second argument that connect
accepts is a function called
mapDispatchToProps
.
The mapDispatchToProps
method gives us access to the dispatch
method and
allows us to pass functions into our component as props that will automatically
dispatch our actions for us. Here is an example:
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { myAction } from './actions';
class MyComponent extends Component {
render() {
return <button onClick={() => this.props.triggerAction('foobar')}>;
}
}
const mapDispatchToProps = dispatch => {
return {
triggerAction: text => dispatch(myAction(text))
};
};
const ConnectedMyComponent = connect(null, mapDispatchToProps)(MyComponent);
export default ConnectedMyComponent;
In this example we import an action called myAction
which we imagine accepts a
text string. We build a mapDispatchToProps
function that returns a prop called
triggerAction
which can take a text
argument and will dispatch the
myAction
action for us with the given text string. In the connect
call we
first pass null
since we don't have to map any state to props. If we did
though, we would pass the mapStateToProps
function here instead. Inside the
component there is a <button>
tag that when clicked calls the triggerAction
prop with a static text "foobar" which will cause the action to be triggered -
meaning all reducers will receive the myAction
action.
Let's try it out by applying this to our Pointer
component.
First we will import our add and remove favourite actions.
// app/js/components/Pointer/Pointer.jsx
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
+ import { addFavourite, removeFavourite } from '../../actions';
import styles from './Pointer.css';
We then have to connect the Pointer
component with Redux.
// app/js/components/Pointer/Pointer.jsx
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
+ import { connect } from 'react-redux';
+ import { addFavourite, removeFavourite } from '../../actions';
import styles from './Pointer.css';
- class Pointer extends Component {
+ export class Pointer extends Component {
...
}
+ const ConnectedPointer = connect()(Pointer);
- export default Pointer;
+ export default ConnectedPointer;
We then update our index.js
file of the Pointer
component to manage the new
exports.
// app/js/components/Pointer/index.js
export { default } from './Pointer';
+ export * from './Pointer';
Next we create our mapDispatchToProps
function that creates props to add and
remove a favourite and pass it to connect
.
// app/js/components/Pointer/Pointer.jsx
+ const mapDispatchToProps = dispatch => {
+ return {
+ addFavourite: index => {
+ dispatch(addFavourite(index));
+ },
+
+ removeFavourite: index => {
+ dispatch(removeFavourite(index));
+ }
+ };
+ };
- const ConnectedPointer = connect()(Pointer);
+ const ConnectedPointer = connect(null, mapDispatchToProps)(Pointer);
Due to the new props our propTypes
definition will have to be updated. The
index
prop will be the array index we want to update; this prop does not yet
exist but we will be passing this to the Pointer
from the Map
in a moment.
// app/js/components/Pointer/Pointer.jsx
Pointer.propTypes = {
+ addFavourite: PropTypes.func,
+ removeFavourite: PropTypes.func,
+ index: PropTypes.number,
details: PropTypes.object.isRequired,
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
favourite: PropTypes.bool
};
We now must remove the internal state
that managed wether the pointer should
be displayed as a favourite or not and replace this logic by calling our Redux
actions instead. Most of the changes are happening inside of the favourite
method that now will take the array index
and based on wether the favourite
prop is currently true or false, call the addFavourite
or removeFavourite
prop (which dispatched the matching action) and passes the index
to it.
// app/js/components/Pointer/Pointer.jsx
class Pointer extends Component {
constructor (props) {
super(props);
this.state = {
- open: false,
- favourite: props.favourite
+ open: false
};
this.toggle = this.toggle.bind(this);
this.favourite = this.favourite.bind(this);
}
toggle (event) {
event.preventDefault();
if (event.target === event.currentTarget) {
this.setState({ open: !this.state.open });
}
}
favourite () {
- this.setState({ favourite: !this.state.favourite });
+ const { index, favourite, removeFavourite, addFavourite } = this.props;
+
+ if (favourite) {
+ removeFavourite(index);
+ } else {
+ addFavourite(index);
+ }
}
render () {
- const { x, y, details } = this.props;
+ const { x, y, details, favourite } = this.props;
const { name, house, words } = details;
const pointerClasses = classNames(styles.pointer, {
- [styles.favourite]: this.state.favourite
+ [styles.favourite]: favourite
});
const detailsClasses = classNames(styles.details, {
[styles.hidden]: !this.state.open
});
return (
<div
className={pointerClasses}
style={{ left: x, top: y }}
onClick={this.toggle}
>
<div className={detailsClasses}>
<header className={styles.headline}>
<h3>{name}</h3>
<div className={styles.detailsControls}>
<a href="#" className={styles.control} onClick={this.favourite}>
- {this.state.favourite ? '–' : '+'}
+ {favourite ? '–' : '+'}
</a>
<a href="#" className={styles.control} onClick={this.toggle}>
×
</a>
</div>
</header>
<p>House: {house}</p>
<p>Words: {words}</p>
</div>
</div>
);
}
}
Finally we have to update the Map
component to pass the array index
that our
Redux actions rely on to our Pointer
component as a prop.
// app/js/components/Map/Map.jsx
export const Map = ({ points }) => {
return (
<div className={styles.map}>
- {points.map((point, index) => <Pointer {...point} key={index} />)}
+ {points.map((point, index) => (
+ <Pointer {...point} index={index} key={index} />
+ ))}
</div>
);
};
The final task of this step is for our reducer to listen to the dispatched actions and respond to them by changing the application state. This will have the the effect of the whole application updating.
Currently our points
reducer does not do anything when it receives and action.
The switch
statement simply falls to the default case and returns the current
state. We will now update the reducer to have additional cases inside of the
switch
statement that listen for the action types we dispatch.
Since our action types are defined in the constants.js
file we will import
these and use them as the values we look for in the switch
.
// app/js/reducers/points.js
+ import { FAVOURITE_ADDED, FAVOURITE_REMOVED } from '../constants';
const initialState = [
...
];
const points = (state = initialState, action) => {
switch (action.type) {
+ case FAVOURITE_ADDED:
+ case FAVOURITE_REMOVED:
default:
return state;
}
};
export default points;
Now when the addFavourite
action is dispatched the FAVOURITE_ADDED
case will
pick it up, just as the removeFavourite
action will map to the
FAVOURITE_REMOVED
case.
We now need to update the state being passed into the reducer based on our
action. We will use the index
passed inside of the payload
section of the
action to identify the item inside of the state we want to update and then
either set the favourite
property of that item to either true
or false
,
depending on whether we want to add or remove the favourite.
It is important to know that reducers never simply update the state
, they must
create a new state
each time the data changes or the update will not be
applied. This concept is called "immutability" and it is how Redux knows wether
the state has actually changed or not.
[A reducer] should be "pure", which means the reducer does not mutate its arguments. If the reducer updates state, it should not modify the existing state object in-place. Instead, it should generate a new object containing the necessary changes. The same approach should be used for any sub-objects within state that the reducer updates. 2
Therefore we cannot simply access the array index from the state
argument and
change the favourite
property but instead copy the item to update, modify it,
then create a new array of all of the original items including the updated one.
In order to simplify this we will add a function called updateFavouriteState
that will allow us to share this logic between the "add" and "remove" cases.
// app/js/reducers/points.js
+ const updateFavouriteState = (index, newValue, points) => {
+ const updatedPoint = points[index];
+ updatedPoint.favourite = newValue;
+
+ return [...points.slice(0, index), updatedPoint, ...points.slice(index + 1)];
+ };
+
const points = (state = initialState, action) => {
+ let index;
+
switch (action.type) {
case FAVOURITE_ADDED:
+ index = action.payload.index;
+ return updateFavouriteState(index, true, state);
+
case FAVOURITE_REMOVED:
+ index = action.payload.index;
+ return updateFavouriteState(index, false, state);
+
default:
return state;
}
};
The updateFavouriteState
function accepts the array index
to update, the new
value for the favourite
property (true
or false
) and the points
array,
which is the state
passed to our reducer.
We then return a new array that has all of the points from before the index, the
updated item, and finally all of the items after the index. We use the ES7 ...
spread operator that "spreads" multiple array items into an array and flattens
them. The final result is not an array of 3 arrays but a single array of all
items.
The Redux documentation contains many more of these immutable update patterns and also contains patterns for other data types such as objects 3.
That's it! If you now look at the browser and play with the map you will see adding or removing a favourite place on the map will not only still update the map marker colour but will also automatically add or remove the items from the list underneath the map because all connected components are now reacting to our dispatched actions and reading the new store state. If you are using the Redux developer tools you will see the actions being dispatched every time you chang a favourite state.
In the next step we will give the favourites list with the ability to remove favourites via Redux.