Taken from Lynda - How Redux Works
What is Redux
The History of Redux
Dan Abramov invented the idea for Redux during a React Europe Conference presentation in 2015. Andrew Clark abandoned Flummox, another Flux implementation, to work with Abramov to complete Redux.
Flux
A design pattern developed by Facebook. An alternative to MVC, MVP, or MVVM.
Models manage the data within an application. Models are presented in Views. Models can feed data to multiple views. When a user interacts with a view, the model may become modified. This can change the data in other views. This can have unexpected consequences in large complex systems.
Flux was developed by Facebook, a pattern where data flows in one direction.
Action -> Dispatcher -> Store -> View
Flux is a design pattern, not a library. Libraries that apply this design pattern include Reflux, Flummox, Fluxxor, Alt, Redux, Marty.js, McFly, DeLorean, Lux, Fluxy, and Material Flux.
Due to simplicity and ease of use, Redux has won out in the community.
How Redux Works
Redux isn’t exactly Flux, it’s Flux-like. Data still flows in one direction, but there is only one store (not multiple). The “single source of truth”.
Moularity is achieved by using functions to manage specific leafs and branches of the state tree.
Using functions for modularity comes from The Functional Programming paradigm.
Functional Programming
- Pure functions - Do not cause side affects. Receive input, and return result. Do not modify arguments, global variables, or other state.
- Immutability - No variables are changed, instead new ones are created.
- Composition - Ability to put functions together in a way that one functions output becomes the next functions input.
Example
Let’s say we want to make a call to getPercent(1,4)
and have it return
the string ‘25%’.
- getPercent(1,4)
- convertToDecimal() - returns
0.25
- decimalToPercent() - returns ‘25’
- addPercentSign() - returns ‘25%’
- convertToDecimal() - returns
import { compose } from "redux"
const getPercent = compose(
addPercentSign,
decimalToPercent,
convertToDecimal
)
getPercent(1, 4)
In Redux composition is used in the store. The reducer functions that we create to manage parts of the state tree are composed. The action and state is piped through each of these reducers until a state is eventually mutated.
Plan a Redux App
Actions
In a Redux application, you want to define your actions.
- ADD_DAY
- REMOVE_DAY
- SET_GOAL
- ADD_ERROR
- CLEAR_ERROR
- FETCH_RESORT_NAMES
- CANCEL_FETCHING
- CHANGE_SUGGESTIONS
- CLEAR_SUGGESTIONS
We want to put these in a file called constants.
// src/constants.js
const constants = {
ADD_DAY: "ADD_DAY",
REMOVE_DAY: "REMOVE_DAY",
SET_GOAL: "SET_GOAL",
ADD_ERROR: "ADD_ERROR",
CLEAR_ERROR: "CLEAR_ERROR",
FETCH_RESORT_NAMES: "FETCH_RESORT_NAMES",
CANCEL_FETCHING: "CANCEL_FETCHING",
CHANGE_SUGGESTIONS: "CHANGE_SUGGESTIONS",
CLEAR_SUGGESTIONS: "CLEAR_SUGGESTIONS"
}
export default constants
This is done to make sure that any typos result in an error when working with these strings that represent the different actions.
State
allSkiDays -> []
skiDay -> {resort, date, powder, backcountry}
goal -> number
errors -> []
resortNames.fetching -> boolean
resortNames.suggestions -> []
// initialState.json
{
"allSkiDays": [
{
"resort": "Kirkwood",
"date": "2016-12-7",
"powder": true,
"backcountry": false
},
{
"resort": "Squaw Valley",
"date": "2016-12-8",
"powder": false,
"backcountry": false
},
{
"resort": "Mt Tallac",
"date": "2016-12-9",
"powder": false,
"backcountry": true
}
],
"goal": 10,
"errors": [],
"resortNames": {
"fetching": false,
"suggestions": ["SquawValley","Snowbird","Stowe","Steamboat"]
}
}
Reducers
We will name the reducer the same thing as the key.
Understanding Reducers
Run Redux with babel-node
npm init
npm install babel-cli --save-dev
npm install babel-preset-latest --save-dev
npm install babel-preset-stage-0 --save-dev
mkdir -p src
mkdir -p src/store
touch .babelrc
touch src/index.js
touch src/constants.js
touch src/initialState.json
touch src/store/reducers.js
// .babelrc
{
"presets": ["latest", "stage-0"]
}
./src/index.js
will automatically get run with the ‘npm start’ command.
// package.json
{
"name": "ski-day-counter",
"version": "1.0.0",
"description": "",
"main": "constants.js",
"scripts": {
"start": "./node_modules/.bin/babel-node ./src/"
},
"author": "",
"license": "ISC",
"devDependencies": {
"babel-cli": "^6.26.0",
"babel-preset-latest": "^6.24.1",
"babel-preset-stage-0": "^6.24.1"
}
}
// src/index.js
import C from "./constants"
import { allSkiDays, goal } from "./initialState.json"
console.log(`
Ski Day Counter
================
The goal is ${goal} days
Initially there are ${allSkiDays.length} ski days in state
Constants (actions)
-------------------
${Object.keys(C).join("\n ")}
`)
Build Your First Reducer
Reducers are pure functions that are designed to manage specific part of your state object.
// src/store/reducers.js
import C from "../constants"
export const goal = (state = 10, action) => {
if (action.type === C.SET_GOAL) {
return parseInt(action.payload)
} else {
return state
}
}
// src/index.js
import C from "./constants"
import { goal } from "./store/reducers"
const state = 10
const action = {
type: C.SET_GOAL,
payload: 15
}
const nextState = goal(state, action)
console.log(`
initial goal: ${state}
action: ${JSON.stringify(action)}
new goal: ${nextState}
`)
initial goal: 10
action: {"type":"SET_GOAL", "payload":15}
new goal: 15
Create object reducers
// src/index.js
import C from "./constants"
import { skiDay } from "./store/reducers"
const state = null
const action = {
type: C.ADD_DAY,
payload: {
resort: "Heavenly",
date: "2016-12-16",
powder: true,
backcountry: false
}
}
const nextState = skiDay(state, action)
console.log(`
initial state: ${state}
action: ${JSON.stringify(action)}
new State: ${JSON.stringify(nextState)}
`)
// src/store/reducers.js
import C from "../constants"
export const goal = (state = 10, action) => {
if (action.type === C.SET_GOAL) {
return parseInt(action.payload)
} else {
return state
}
}
export const skiDay = (state = null, action) => {
if (action.type === C.ADD_DAY) {
return action.payload
} else {
return state
}
}
Console Output:
initial state: null
action: {"type":"ADD_DAY", "payload":{"resort":"Heavenly","date":"2016-12-16","powder":true,"backcountry":false}}
new state: {"resort":"Heavenly","date":"2016-12-16","powder":true,"backcountry":false}
Refactor for oneline conditionals
// src/store/reducers.js
import C from "../constants"
export const goal = (state = 10, action) =>
action.type === C.SET_GOAL ? parseInt(action.payload) : state
export const skiDay = (state = null, action) =>
action.type === C.ADD_DAY ? action.payload : state
Create Array Reducers
Adding Errors
// src/index.js
import C from "./constants"
import { errors } from "./store/reducers"
const state = ["user not authorized", "server feed not found"]
const action = {
type: C.ADD_ERROR,
payload: "cannot connect to server"
}
const nextState = errors(state, action)
console.log(`
initial state: ${state}
action: ${JSON.stringify(action)}
new State: ${JSON.stringify(nextState)}
`)
// src/store/reducers.js
import C from "../constants"
export const goal = (state = 10, action) => {
if (action.type === C.SET_GOAL) {
return parseInt(action.payload)
} else {
return state
}
}
export const skiDay = (state = null, action) => {
if (action.type === C.ADD_DAY) {
return action.payload
} else {
return state
}
}
export const error = (state = [], action) => {
switch (action.type) {
case C.ADD_ERROR:
// we don't want to mutate the actual state, we need to return a new object
// state.push(action.payload)
return [...state, action.payload]
default:
return state
}
}
Console Output:
initial state: user not authorized, server feed not found
action: {"type":"ADD_ERROR", "payload":"cannot connect to server"}
new state: ["user not authorized","server feed not found","cannot connect to server"]
Clearing Errors
// src/index.js
import C from "./constants"
import { errors } from "./store/reducers"
const state = ["user not authorized", "server feed not found"]
const action = {
type: C.CLEAR_ERROR,
payload: 0
}
const nextState = errors(state, action)
console.log(`
initial state: ${state}
action: ${JSON.stringify(action)}
new State: ${JSON.stringify(nextState)}
`)
// src/store/reducers.js
import C from "../constants"
// ...
export const error = (state = [], action) => {
switch (action.type) {
case C.ADD_ERROR:
// we don't want to mutate the actual state, we need to return a new object
// state.push(action.payload)
return [...state, action.payload]
case C.CLEAR_ERROR:
return state.filter((message, i) => i !== action.payload)
default:
return state
}
}
Console Output:
initial state: user not authorized, server feed not found
action: {"type":"CLEAR_ERROR", "payload":0}
new state: ["server feed not found"]
Composing Reducers
Adding a Day
// src/index.js
import C from "./constants"
import { allSkiDays } from "./store/reducers"
const state = [
{
resort: "Kirkwood",
date: "2016-12-15",
powder: true,
backcountry: false
}
]
const action = {
type: C.ADD_DAY,
payload: {
resort: "Boreal",
date: "2016-12-16",
powder: false,
backcountry: false
}
}
const nextState = allSkiDays(state, action)
console.log(`
initial state: ${JSON.stringify(state)}
action: ${JSON.stringify(action)}
new State: ${JSON.stringify(nextState)}
`)
// src/store/reducers.js
import C from "../constants"
export const skiDay = (state = null, action) =>
action.type === C.ADD_DAY ? action.payload : state
// ...
export const allSkiDays = (state = [], action) => {
switch (action.type) {
case C.ADD_DAY:
return [...state, skiDay(null, action)]
default:
state
}
}
Console Output:
initial state: [{"resort":"Kirkwood","date":"2016-12-15","powder":true,"backcountry":false}]
action: {"type":"ADD_DAY","payload":{"resort":"Boreal","date":"2016-12-16","powder":false,"backcountry":false}}
new State: [
{"resort":"Kirkwood","date":"2016-12-15","powder":true,"backcountry":false},
{"resort":"Boreal","date":"2016-12-16","powder":false,"backcountry":false}
]
Avoiding a Duplicate Day
// src/store/reducers.js
import C from "../constants"
export const skiDay = (state = null, action) =>
action.type === C.ADD_DAY ? action.payload : state
// ...
export const allSkiDays = (state = [], action) => {
switch (action.type) {
case C.ADD_DAY:
const hasDay = state.some(skiDay => skiDay.date === action.payload.date)
return hasDay ? state : [...state, skiDay(null, action)]
default:
state
}
}
Console Output:
initial state: [{"resort":"Kirkwood","date":"2016-12-15","powder":true,"backcountry":false}]
action: {"type":"ADD_DAY","payload":{"resort":"Boreal","date":"2016-12-16","powder":false,"backcountry":false}}
new State: [{"resort":"Kirkwood","date":"2016-12-15","powder":true,"backcountry":false},{"resort":"Boreal","date":"2016-12-16","powder":false,"backcountry":false}]
Removing a Day
// src/index.js
import C from "./constants"
import { allSkiDays } from "./store/reducers"
const state = [
{
resort: "Kirkwood",
date: "2016-12-15",
powder: true,
backcountry: false
},
{
resort: "Boreal",
date: "2016-12-16",
powder: false,
backcountry: false
}
]
const action = {
type: C.REMOVE_DAY,
payload: "2016-12-15"
}
const nextState = allSkiDays(state, action)
console.log(`
initial state: ${JSON.stringify(state)}
action: ${JSON.stringify(action)}
new State: ${JSON.stringify(nextState)}
`)
// src/store/reducers.js
import C from "../constants"
export const skiDay = (state = null, action) =>
action.type === C.ADD_DAY ? action.payload : state
// ...
export const allSkiDays = (state = [], action) => {
switch (action.type) {
case C.ADD_DAY:
return [...state, skiDay(null, action)]
case C.REMOVE_DAY:
return state.filter(skiDay => skiDay.date !== action.payload)
default:
return state
}
}
Console Output:
initial state: [{"resort":"Kirkwood","date":"2016-12-15","powder":true,"backcountry":false},{"resort":"Boreal","date":"2016-12-16","powder":false,"backcountry":false}]
action: {"type":"REMOVE_DAY","payload":"2016-12-15"}
new State: [{"resort":"Boreal","date":"2016-12-16","powder":false,"backcountry":false}]
Combine Reducers
We’re going to make use of a method called combineReducers
provided by Redux.
import C from "../constants"
import { combineReducers } from "redux"
export const goal = (state = 10, action) =>
action.type === C.SET_GOAL ? parseInt(action.payload) : state
export const skiDay = (state = null, action) =>
action.type === C.ADD_DAY ? action.payload : state
export const errors = (state = [], action) => {
switch (action.type) {
case C.ADD_ERROR:
return [...state, action.payload]
case C.CLEAR_ERROR:
return state.filter((message, i) => i !== action.payload)
default:
return state
}
}
export const allSkiDays = (state = [], action) => {
switch (action.type) {
case C.ADD_DAY:
const hasDay = state.some(skiDay => skiDay.date === action.payload.date)
return hasDay
? state
: [...state, skiDay(null, action)].sort(
(a, b) => new Date(b.date) - new Date(a.date)
)
case C.REMOVE_DAY:
return state.filter(skiDay => skiDay.date !== action.payload)
default:
return state
}
}
export const fetching = (state = false, action) => {
switch (action.type) {
case C.FETCH_RESORT_NAMES:
return true
case C.CANCEL_FETCHING:
return false
case C.CHANGE_SUGGESTIONS:
return false
default:
return state
}
}
export const suggestions = (state = [], action) => {
switch (action.type) {
case C.CLEAR_SUGGESTIONS:
return []
case C.CHANGE_SUGGESTIONS:
return action.payload
default:
return state
}
}
const resortNames = combineReducers({
fetching,
suggestions
})
const singleReducer = combineReducers({
allSkiDays,
goal,
errors,
resortNames
})
export default singleReducer
We can use less code to accomplish the same thing like so:
import C from "../constants"
import { combineReducers } from "redux"
// ...
export default combineReducers({
allSkiDays,
goal,
errors,
resortNames: combineReducers({
fetching,
suggestions
})
})
Now let’s test this out in our index.js
// index.js
import C from "./constants"
import appReducer from "./store/reducers"
import initialState from "./initialState.json"
let state = initialState
console.log(`
Initial State
==============
goal: ${state.goal}
resorts: ${JSON.stringify(state.allSkiDays)}
fetching: ${state.resortNames.fetching}
suggestions: ${state.resortNames.suggestions}
`)
state = appReducer(state, {
type: C.SET_GOAL,
payload: 2
})
state = appReducer(state, {
type: C.ADD_DAY,
payload: {
resort: "Mt Shasta",
date: "2016-10-28",
powder: false,
backcountry: true
}
})
state = appReducer(state, {
type: C.CHANGE_SUGGESTIONS,
payload: ["Mt Tallac", "Mt Hood", "Mt Shasta"]
})
console.log(`
Next State
==============
goal: ${state.goal}
resorts: ${JSON.stringify(state.allSkiDays)}
fetching: ${state.resortNames.fetching}
suggestions: ${state.resortNames.suggestions}
`)
Console Output:
Initial State
==============
goal: 10
resorts: [{"resort":"Kirkwood","date":"2016-12-7","powder":true,"backcountry":false},{"resort":"Squaw Valley","date":"2016-12-8","powder":false,"backcountry":false},{"resort":"Mt Tallac","date":"2016-12-9","powder":false,"backcountry":true}]
fetching: false
suggestions: Squaw Valley,Snowbird,Stowe,Steamboat
Next State
==============
goal: 2
resorts: [{"resort":"Mt Tallac","date":"2016-12-9","powder":false,"backcountry":true},{"resort":"Squaw Valley","date":"2016-12-8","powder":false,"backcountry":false},{"resort":"Kirkwood","date":"2016-12-7","powder":true,"backcountry":false},{"resort":"Mt Shasta","date":"2016-10-28","powder":false,"backcountry":true}]
fetching: false
suggestions: Mt Tallac,Mt Hood,Mt Shasta
The Store
Create a static build with webpack
We need to install webpack and the webpack dev server
npm install webpack --save-dev
npm install webpack-dev-server --save-dev
We need to use loaders, which are the instructions that webpack follows when transpiling our code and creating the bundle.
We need to install the Babel loader that converts our ES6 into ES5 compatible JavaScript.
npm install babel-loader --save-dev
npm install babel-core --save-dev
npm install json-loader --save-dev
We need to create a webpack configuration file - webpack.config.js
.
// webpack.config.js
module.exports = {
entry: "./src/index.js"
}
This tells Webpack which file to start with to perform the bundling on.
We have an HTML file under dist/index.html
. This is the file
which the browser will run.
<!DOCTYPE html>
<html>
<head>
<meta
name="viewport"
content="minimum-scale=1.0, width=device-width, maximum-scale=1.0, user-scalable=no"
/>
<meta charset="utf-8" />
<title>Ski Day Counter</title>
</head>
<body>
<div id="react-container"></div>
<script src="assets/bundle.js"></script>
</body>
</html>
As you can see it references assets/bundle.js
, which is the file
we want Webpack to bundle our Javascript into.
We can specify this in our webpack configuration.
// webpack.config.js
module.exports = {
entry: "./src/index.js",
output: {
path: "dist/assets",
filename: "bundle.js",
publicPath: "assets"
}
}
Next we can configure how the Webpack-Dev should operate.
// webpack.config.js
module.exports = {
entry: "./src/index.js",
output: {
path: "dist/assets",
filename: "bundle.js",
publicPath: "assets"
},
devServer: {
inline: true,
contentBase: "./dist",
port: 3000
}
}
The inline mode causes a script to be inserted in the bundle to take care of live reloading. Build messages will appears in the browser console.
There is also an iframe mode, where the page is iframed under a notification bar with messages about the build.
Next we can configure Webpack to use the Babel loader.
// webpack.config.js
module.exports = {
entry: "./src/index.js",
output: {
path: "dist/assets",
filename: "bundle.js",
publicPath: "assets"
},
devServer: {
inline: true,
contentBase: "./dist",
port: 3000
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /(node_modules)/,
loader: ["babel"],
query: {
presets: ["latest", "stage-0"]
}
}
]
}
}
If we import a module that has any ES6 or other emerging JavaScript syntax,
it will be included in the bundle.js as ES5 compatible JavaScript. We
want to run the Babel loader on any file that ends in .js
. This is what
the ‘test’ regular expression does. We’re also choosing to exclude
anything loaded from the ‘node_modules’ folder.
We also originally setup presets for our Babel-node command. We want to make sure we include the same presets for Babel in our Webpack config.
// .babelrc
{
"presets": ["latest", "stage-0"]
}
Note: Stage presents are being deprecated with Babel v7.
Lastly, we need to add a loader for including JSON files in our bundle.
// webpack.config.js
module.exports = {
entry: "./src/index.js",
output: {
path: "dist/assets",
filename: "bundle.js",
publicPath: "assets"
},
devServer: {
inline: true,
contentBase: "./dist",
port: 3000
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /(node_modules)/,
loader: ["babel"],
query: {
presets: ["latest", "stage-0"]
}
},
{
test: /\.json$/,
exclude: /(node_modules)/,
loader: "json-loader"
}
]
}
}
In our package.json
you can see that all our dependencies have been
put under ‘devDependencies’. You’ll remember that we configured
the default script for npm start
was to use ‘babel-node’ to run
our app.
// package.json
{
"name": "ski-day-counter",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "babel-node ./src"
},
"author": "",
"license": "ISC",
"dependencies": {
"redux": "^3.6.0"
},
"devDependencies": {
"babel-core": "^6.18.0",
"babel-loader": "^6.2.6",
"babel-preset-latest": "^6.16.0",
"babel-preset-stage-0": "^6.16.0",
"json-loader": "^0.5.4",
"webpack": "^1.13.3",
"webpack-dev-server": "^1.16.2"
}
}
Instead we’re going to change this to use the Webpack-Dev-Server instead.
"scripts": {
"start": "./node_modules/.bin/webpack-dev-server"
},
All executables installed by NPM are placed in ./node_modules/.bin
.
Webpack-Dev-Server will automatically start the Express server for us on port 3000.
$ npm start
> ski-day-counter@1.0.0 start /Users/jasonmiller/Projects/redux/exercises/Ch03/03_01/start
> webpack-dev-server
http://localhost:3000/
webpack result is served from /assets
content is served from ./dist
Hash: 2afa1e19c1068e8225ac
Version: webpack 1.15.0
Time: 782ms
Asset Size Chunks Chunk Names
bundle.js 286 kB 0 [emitted] main
chunk {0} bundle.js (main) 265 kB [rendered]
[0] multi main 40 bytes {0} [built]
[1] (webpack)-dev-server/client?http://localhost:3000 4.16 kB {0} [built]
...
...
[96] ./~/redux/lib/compose.js 927 bytes {0} [built]
[97] ./src/initialState.json 381 bytes {0} [built]
webpack: Compiled successfully.
Create a store
We’ve combined all our reducers into a single appReducer. With Redux we don’t have to use this because the store will do this for us.
The ‘createStore’ function provided by Redux is used to build instance of Redux stores.
// src/index.js
import C from "./constants"
import appReducer from "./store/reducers"
import initialState from "./initialState.json"
import { createStore } from "redux"
const store = createStore(appReducer)
console.log("initial state", store.getState())
By default, just using the appReducer, our initial state will be created by using all of the default variables we defined in every reducer. For instance our goal value defaults to ‘10’ and our allSkiDays was set to an empty array.
Once every reducer is invoked once, the default value for that reducer will be stored as the initial state.
The store also provides the dispatch
method used to dispatch
actions that mutate the state.
// src/index.js
import C from "./constants"
import appReducer from "./store/reducers"
import initialState from "./initialState.json"
import { createStore } from "redux"
const store = createStore(appReducer)
console.log("initial state", store.getState())
store.dispatch({
type: C.ADD_DAY,
payload: {
resort: "Mt Shasta",
date: "2016-10-28",
powder: false,
backcountry: true
}
})
console.log("next state", store.getState())
Now we run our server, access our browser via http://localhost:3000/, and we look at the console.
npm start
The createStore
method will also accept an object to use for initialState.
const store = createStore(appReducer, initialState)
After making this modification to index.js
and saving the file,
our Webpack-Dev-Server will reload the page and we’ll see the new outcome.
Console Output:
initial state
{allSkiDays: Array(3), goal: 10, errors: Array(0), resortNames: {…}}
allSkiDays: Array(3)
0: {resort: "Kirkwood", date: "2016-12-7", powder: true, backcountry: false}
1: {resort: "Squaw Valley", date: "2016-12-8", powder: false, backcountry: false}
2: {resort: "Mt Tallac", date: "2016-12-9", powder: false, backcountry: true}
length: 3
__proto__: Array(0)
errors: []
goal: 10
resortNames: {fetching: false, suggestions: Array(4)}
__proto__: Object
next state
{allSkiDays: Array(4), goal: 10, errors: Array(0), resortNames: {…}}
allSkiDays: Array(4)
0: {resort: "Mt Tallac", date: "2016-12-9", powder: false, backcountry: true}
1: {resort: "Squaw Valley", date: "2016-12-8", powder: false, backcountry: false}
2: {resort: "Kirkwood", date: "2016-12-7", powder: true, backcountry: false}
3: {resort: "Mt Shasta", date: "2016-10-28", powder: false, backcountry: true}
length: 4
__proto__: Array(0)
errors: []
goal: 10
resortNames: {fetching: false, suggestions: Array(4)}
__proto__: Object
Subscribe to the store
It’s possible to subscribe to the store so that your callback methods are called anytime the state changes.
import C from "./constants"
import appReducer from "./store/reducers"
import { createStore } from "redux"
const store = createStore(appReducer)
store.subscribe(() => console.log(store.getState()))
store.dispatch({
type: C.ADD_DAY,
payload: {
resort: "Mt Shasta",
date: "2016-10-28",
powder: false,
backcountry: true
}
})
store.dispatch({
type: C.SET_GOAL,
payload: 2
})
Console Output:
{allSkiDays: Array(1), goal: 10, errors: Array(0), resortNames: {…}}
allSkiDays: Array(1)
0: {resort: "Mt Shasta", date: "2016-10-28", powder: false, backcountry: true}
length: 1
__proto__: Array(0)
errors: []
goal: 10
resortNames: {fetching: false, suggestions: Array(0)}
__proto__: Object
{allSkiDays: Array(1), goal: 2, errors: Array(0), resortNames: {…}}
allSkiDays: Array(1)
0: {resort: "Mt Shasta", date: "2016-10-28", powder: false, backcountry: true}
length: 1
__proto__: Array(0)
errors: []
goal: 2
resortNames: {fetching: false, suggestions: Array(0)}
__proto__: Object
We can even use a subscriber to store data to local storage.
store.subscribe(() => {
const state = JSON.stringify(store.getState())
localStorage["redux-store"] = state
})
We can then load this data from local storage when our application loads.
import C from "./constants"
import appReducer from "./store/reducers"
import { createStore } from "redux"
const initialState = localStorage["redux-store"]
? JSON.parse(localStorage["redux-store"])
: {}
const store = createStore(appReducer, initialState)
window.store = store
store.subscribe(() => {
const state = JSON.stringify(store.getState())
localStorage["redux-store"] = state
})
store.dispatch({
type: C.SET_GOAL,
payload: 2
})
It’s possible to add your store to window
, which might be helpful for
debugging, but you don’t want to leave that in place in production.
const store = createStore(appReducer, initialState)
window.store = store
Console:
> store.getState();
< {allSkiDays: Array(0), goal: 10, errors: Array(0), resortNames: {…}}
You can view the data in localStorage as well, as a JSON string.
Console:
> localStorage
< Storage {redux-store: "{"allSkiDays":[],"goal":2,"errors":[],"resortNames":{"fetching":false,"suggestions":[]}}", loglevel:webpack-dev-server: "INFO", length: 2}
You can clear localStorage by using localStorage.clear()
.
> localStorage.clear()
< undefined
> localStorage
< Storage {length: 0}
Now the key is gone. When we refresh, and it makes the first mutation to the state, the current state is saved to localStorage, and loaded when the page refreshes.
Unsubscribe from the store
It’s also possible to turn off store subscriptions using unsubscribe()
.
Let’s say we have this subscription to load the state every time it’s modified, and we’re using a loop (ever 250 milliseconds, 4 times a second) to change the goal to a random number.
import C from "./constants"
import appReducer from "./store/reducers"
import { createStore } from "redux"
const store = createStore(appReducer)
store.subscribe(() => console.log(` Goal: ${store.getState().goal}`))
setInterval(() => {
store.dispatch({
type: C.SET_GOAL,
payload: Math.floor(Math.random() * 100)
})
}, 250)
When you call store.subscribe()
, it returns a function that can be used to
unsubscribe.
import C from "./constants"
import appReducer from "./store/reducers"
import { createStore } from "redux"
const store = createStore(appReducer)
const unsubscribeGoalLogger = store.subscribe(() =>
console.log(` Goal: ${store.getState().goal}`)
)
setInterval(() => {
store.dispatch({
type: C.SET_GOAL,
payload: Math.floor(Math.random() * 100)
})
}, 250)
setTimeout(() => {
unsubscribeGoalLogger()
}, 3000)
The output in the console should be like so, running for only 3 seconds:
Goal: 40
Goal: 45
Goal: 58
Goal: 86
Goal: 13
Goal: 91
Goal: 35
Goal: 98
Goal: 9
Goal: 48
Goal: 41
Goal: 47
Create middleware
Middleware gives you control over how actions are dispatched. You can add functionality before or after the action is dispatched. We can delay actions, or skip them altogether.
Here’s a simple way of establishing our store.
// store/index.js
import C from "../constants"
import appReducer from "./reducers"
import { createStore } from "redux"
export default (initialState = {}) => {
return createStore(appReducer, initialState)
}
Middleware uses a Higher-Order Function, that is, a function that takes a function as an argument, or returns a function.
Let’s make a method to log messages to the console. The store is going to be injected into this function.
const consoleMessages = function(store) {
return function(next) {
return function(action) {
// ...
}
}
}
We can write this more simply like so using ES6 syntax:
const consoleMessages = store => next => action => {
// ...
}
Because each arrow function only have one argument, the parenthesis aren’t necessary. This function only dispatches the action. This makes sure that we are not breaking the stores current dispatch pipeline.
We can add functionality before or after the dispatching of the action as needed with this function, thus modifying the pipeline… thus middleware.
const consoleMessages = store => next => action => {
let result
result = next(action)
return result
}
Let’s create a console group before we dispatch the action. Console groups allow us to group all of the logs associated with this action into a collapsible group in the console.
We replace the createStore
method in our exported default method with a call
to applyMiddleware
. It returns a store with our middleware applied, which we
want to send the createStore
function to, which we want to pass our
appReducer
and initialState
to.
// src/store/index.js
import appReducer from "./reducers"
import { createStore, applyMiddleware } from "redux"
const consoleMessages = store => next => action => {
let result
console.groupCollapsed(`dispatching action => ${action.type}`)
console.log("ski days", store.getState().allSkiDays.length)
result = next(action)
let { allSkiDays, goal, errors, resortNames } = store.getState()
console.log(`
ski days: ${allSkiDays.length}
goal: ${goal}
fetching: ${resortNames.fetching}
suggestions: ${resortNames.suggestions}
errors: ${errors.length}
`)
console.groupEnd()
return result
}
export default (initialState = {}) => {
return applyMiddleware(consoleMessages)(createStore)(appReducer, initialState)
}
Let’s use this with our main code.
// src/index.js
import C from "./constants"
import storeFactory from "./store"
const initialState = localStorage["redux-store"]
? JSON.parse(localStorage["redux-store"])
: {}
const saveState = () => {
const state = JSON.stringify(store.getState())
localStorage["redux-store"] = state
}
const store = storeFactory(initialState)
store.subscribe(saveState)
store.dispatch({
type: C.ADD_DAY,
payload: {
resort: "Mt Shasta",
date: "2016-10-28",
powder: true,
backcountry: true
}
})
store.dispatch({
type: C.ADD_DAY,
payload: {
resort: "Squaw Valley",
date: "2016-3-28",
powder: true,
backcountry: false
}
})
store.dispatch({
type: C.ADD_DAY,
payload: {
resort: "The Canyons",
date: "2016-1-2",
powder: false,
backcountry: true
}
})
Our console output:
dispatching action => ADD_DAY
ski days 0
ski days: 1
goal: 2
fetching: false
suggestions:
errors: 0
dispatching action => ADD_DAY
ski days 1
ski days: 2
goal: 2
fetching: false
suggestions:
errors: 0
dispatching action => ADD_DAY
ski days 2
ski days: 3
goal: 2
fetching: false
suggestions:
errors: 0
Action Creators
What are action creators
With Redux the store is only intended to manage state data. It should not contain application logic such as generating unique ids, reading or writing data to a persistence layer, changing global variables, or fetching data from a REST endpoint via AJAX request.
Your application should use the store, the store should not be your application.
So where should our logic go?
Action creators are functions that create and return actions, allowing us to encapsulate the logic of our application using functions not objects.
// src/index.js
import storeFactory from "./store"
import { addDay } from "./actions"
const store = storeFactory()
store.dispatch(addDay("Heavenly", "2016-12-22"))
If you need to add application specific logic, you could do it within the action creator.
// src/actions.js
import C from "./constants"
export function addDay(resort, date, powder = false, backcountry = false) {
// Add app logic here if needed
return {
type: C.ADD_DAY,
payload: { resort, date, powder, backcountry }
}
}
Let’s add an action creator for removing a day.
// src/actions.js
import C from "./constants"
export function addDay(resort, date, powder = false, backcountry = false) {
// Add app logic here if needed
return {
type: C.ADD_DAY,
payload: { resort, date, powder, backcountry }
}
}
export const removeDay = function(date) {
return {
type: C.REMOVE_DAY,
payload: date
}
}
export const setGoal = goal => ({
type: C.SET_GOAL,
payload: goal
})
Let’s add those to our main script.
// src/index.js
import storeFactory from "./store"
import { addDay, removeDay, setGoal } from "./actions"
const store = storeFactory()
store.dispatch(addDay("Heavenly", "2016-12-22"))
store.dispatch(removeDay("2016-12-22"))
store.dispatch(setGoal(55))
Async actions with redux-thunk
Your logic often has to deal with asynchronicity, such as asynchronous requests to a server. We need to be able to work with action creators that will wait for a response before dispatching an action.
Redux-Thunk is middleware that we can add to our store. Thunks are higher-order functions that give you control over when and how often actions are dispatched.
Redux-thunk looks at every action that is dispatched, and if it’s a function, it calls that function.
npm install redux-thunk --save
// src/store/index.js
import C from "../constants"
import appReducer from "./reducers"
import thunk from "redux-thunk"
import { createStore, applyMiddleware } from "redux"
const consoleMessages = store => next => action => {
let result
// ...
return result
}
export default (initialState = {}) => {
return applyMiddleware(thunk, consoleMessages)(createStore)(
appReducer,
initialState
)
}
Just like other action creators, Thunks are functions.
We’re going to dispatch this just like any other action creator. The difference is that Thunks don’t return the action object directly, they return another function.
We can call dispatch actions as often as we like from within a Thunk, and we can also delay the dispatch.
Because Thunks get the dispatch function, we have control over when and how
often we’re going to dispatch actions. We can also use getState()
to check
the state before dispatching actions.
// src/store/reducers.js
// ...
export const fetching = (state = false, action) => {
switch (action.type) {
case C.FETCH_RESORT_NAMES:
return true
case C.CANCEL_FETCHING:
return false
case C.CHANGE_SUGGESTIONS:
return false
default:
return state
}
}
// ...
// src/actions.js
export const randomGoals = () => (dispatch, getState) => {
if (!getState().resortNames.fetching) {
dispatch({
type: C.FETCH_RESORT_NAMES
})
setTimeout(() => {
dispatch({
type: C.CANCEL_FETCHING
})
}, 1500)
}
}
So in this case, if we’re not currently fetching resort names, then we’ll start the process of fetching them. After a second and a half, it will dispatch the action to cancel the fetching.
// src/index.js
import storeFactory from "./store"
import { randomGoals } from "./actions"
const store = storeFactory()
store.dispatch(randomGoals())
Terminal Output:
dispatching action => FETCH_RESORT_NAMES
dispatching action => CANCEL_FETCHING
What if we dispatched our randomGoals()
twice?
Terminal Output:
dispatching action => FETCH_RESORT_NAMES
dispatching action => CANCEL_FETCHING
This is because the state of ‘fetching’ became true.
Autocomplete thunk
Let’s imagine that we have an API end-point running on an Express back-end,
accessible from /resorts/{search string}
. For example, a request to
/resorts/hea
returns ["Heavenly Ski Resort", "Heavens Sonohara"]
.
We want to use this to provide suggestions of resorts to choose from in a search field.
In order to make an AJAX request to the suggestions server, we’ll use a library called isomorphic-fetch.
npm install isomorphic-fetch -save
This library is an implementation of the whatwg fetch specification that works in NodeJS and the browser. This is a standard for fetching resources from APIs.
// src/index.js
import storeFactory from "./store"
import { suggestResortNames } from "./actions"
const store = storeFactory()
store.dispatch(suggestResortNames("hea"))
// src/actions.js
import C from './constants'
import fetch from 'isomorphic-fetch'
export function addDay(resort, date, powder=false, backcountry=false) {
return {
type: C.ADD_DAY,
payload: { resort, date, powder, backcountry }
}
}
// ...
export const suggestResortNames = value => (dispatch) {
dispatch({
type: C.FETCH_RESORT_NAMES
})
fetch('http://localhost:3333/resorts/' + value)
.then(response => response.json())
.then(suggestions => {
dispatch({
type: C.CHANGE_SUGGESTIONS,
payload: suggestions
})
})
.catch(error => {
dispatch({
addError(error.message)
})
dispatch({
type: C.CANCEL_FETCHING
})
})
}
Our function returned by the thunk suggestResortNames
could accept both the
dispatch
and getState
methods, but it only needs the dispatch
function.
Console Output:
dispatching action => FETCH_RESORT_NAMES
dispatching action => CHANGE_SUGGESTIONS
A half second after the first line, the CHANGE_SUGGESTIONS
shows up after
the suggestions are received from the API and added to the state.
You can stop the server you’re running and refresh the page, and this will result in the errors.
Console Output:
dispatching action => FETCH_RESORT_NAMES
dispatching action => ADD_ERROR
dispatching action => CANCEL_FETCHING
Incorporating React
React app overview
Thus far we’ve used Redux to construct the client data layer for our application. It’s now time to implement the user interface layer for our new store.
- src
- components
- containers
- ui
- index.js
- store
- index.js
- reducers.js
- stylesheets
- index.scss
- Menu.scss
- ShowErrors.scss
- SkiDayList.scss
- actions.js
- constants.js
- index.js
- initialState.json
- routes.js
- components
React-Redux helps us integrate our store with our React components.
// src/index.js
import C from "./constants"
import React from "react"
import { render } from "react-dom"
import routes from "./routes"
import sampleData from "./initialState"
const initialState = localStorage["redux-store"]
? JSON.parse(localStorage["redux-store"])
: sampleData
const saveState = () =>
(localStorage["redux-store"] = JSON.stringify(store.getState()))
window.React = React
render(routes, document.getElementById("react-container"))
Let’s bring our store in.
// src/index.js
import C from "./constants"
import React from "react"
import { render } from "react-dom"
import routes from "./routes"
import sampleData from "./initialState"
import storeFactory from "./store"
const initialState = localStorage["redux-store"]
? JSON.parse(localStorage["redux-store"])
: sampleData
const saveState = () =>
(localStorage["redux-store"] = JSON.stringify(store.getState()))
const store = storeFactory(initialState)
store.subscribe(saveState)
// to aid with interacting from JS console
window.React = React
window.store = store
render(routes, document.getElementById("react-container"))
We need to be able to pass the store down to our component tree. React Redux has a compnent we can use called Provider that does this.
import { Provider } from "react-redux"
You can wrap the Provider component around any component tree, and it will place the store in Context. Context is a feature that will allow any child React component to interact with the store if needed.
render(
<Provider store={store}>{routes}</Provider>,
document.getElementById("react-container")
)
This will place the store in context so that it’s accessible by any of the child components listed under routes.
Map props to React components
We’re going to wire up the ski day count.
In the folder structure outlined above, the components in src/components
are
organized under either the containers
folder or ui
folder.
The ui
folder contains user interface components, which are pure react
components. They communicate solely through properties. They pass data back
up to their parents through two-way data binding, and they receive data from
properties as well.
The container
folder contains wrappers used to feed data to our components.
For example, the following container component is a stateless functional component that wraps around the SkiDayCount component. Currently the variables being passed are hardcoded. We want this map data from our store to the properties of the SkiDayCount component.
// src/components/containers/skiDayCount.js
import SkiDayCount from "../ui/SkiDayCount"
export default () => <SkiDayCount total={100} powder={25} backcountry={10} />
To do this we’ll use connect
provided by react-redux
that creates a
component that grabs the store out of state, and can map state from the store
to properties in a child component.
We need to define a function that receives the state and returns an object
that contains keys for the properties of the SkiDayCount
component,
and values that represent the values we want passed into the component. This
is defined below in mapStateToProps
.
The connect
function is a higher order function. It takes our
mapStateToProps
function as an argument, and it returns a
function that expects the component we wish to wrap as it’s first argument
(SkiDayCount).
// src/components/containers/skiDayCount.js
import SkiDayCount from "../ui/SkiDayCount"
import { connect } from "react-redux"
const mapStateToProps = state => {
return {
total: state.allSkiDays.length,
powder: state.allSkiDays.filter(day => day.powder).length,
backcountry: state.allSkiDays.filter(day => day.backcountry).length
}
}
const Container = connect(mapStateToProps)(SkiDayCount)
export default Container
Map dispatch to React components
Next we want to work with a component that displays errors.
If the user chooses to close the error, a ‘CLEAR_ERROR’ action should be dispatched.
Below we have our ShowErrors component. We need to replace the errors with
our action errors from the data store, and also pass a function to the
component for the onClearError
property that will dispatch the ‘CLEAR_ERROR’
action.
// src/components/ShowErrors.js
import ShowErrors from "../ui/ShowErrors"
export default () => (
<ShowErrors
errors={["sample error"]}
onClearError={index => console.log("todo: clear error at", index)}
/>
)
// src/components/ShowErrors.js
import ShowErrors from "../ui/ShowErrors"
import { clearError } from "../../actions"
import { connect } from "react-redux"
const mapStateToProps = state => {
return {
errors: state.errors
}
}
const mapDispatchToProps = dispatch => {
return {
onClearError(index) {
dispatch(clearError(index))
}
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ShowErrors)
We want to make sure that any errors that occur get recorded in state. Anytime an error occurs, we want to add this to the state.
// src/index.js
import C from "./constants"
import React from "react"
import { render } from "react-dom"
import routes from "./routes"
import sampleData from "./initialState"
import storeFactory from "./store"
import { Provider } from "react-redux"
import { addError } from "./actions"
// ...
const handleError = error => {
store.dispatch(addError(error))
}
// ...
window.addEventListener("error", handleError)
If we add a call at the bottom of our file now, such as foo = bar
,
we get Uncaught ReferenceError: bar is not defined
added to our
errors.
Console:
dispatching action => ADD_ERROR
Uncaught ReferenceError: bar is not defined(...)
Now any errors that occur with our application are displayed in the UI.
Map router params to React components
In our routes we go to /list-days/
to view all the ski days. If instead we go
to /list-days/backcountry
we want a filter applied that only shows the
backcountry days, or if we go to /list-days/powder
we want to only see the
powder days.
So for our ListSkiDays component we’re going to need to pass not only the list of days, but also the router parameter that represents the filter.
Also, if the user double clicks on any of the days, we should remove that day from the list.
Here is how our container is configured, with sample list data and a console log statement when an item is double clicked.
// src/components/containers/SkiDayList.js
import SkiDayList from "../ui/SkiDayList"
const sample = [
{
resort: "Stowe",
date: "2017-1-28",
powder: false,
backcountry: false
},
{
resort: "Tuckerman's Ravine",
date: "2017-1-31",
powder: false,
backcountry: true
},
{
resort: "Mad River Glen",
date: "2017-2-12",
powder: true,
backcountry: false
}
]
export default props => (
<SkiDayList
days={sample}
filter={props.params.filter}
onRemoveDay={date => console.log("remove day on", date)}
/>
)
An arrow function will return whatever is on the other side of the arrow, so
// src/components/containers/SkiDayList.js
import SkiDayList from "../ui/SkiDayList"
import { connect } from "react-redux"
import { removeDay } from "../../actions"
const mapStateToProps = (state, props) => ({
days: state.allSkiDays,
filter: props.params.filter
})
const mapDispatchToProps = dispatch => ({
onRemoveDay(date) {
dispatch(removeDay(date))
}
})
export default connect(mapStateToProps.mapDispatchToProps)(SkiDayList)