There are a lot of moving parts in Redux and its associated libraries. It's challenging for newcomers to figure out what's going on -- especially when you mix in React, React-Router, React-Redux, React-Router-Redux, etc. (Yes, it's common to have all four of those libraries in the same project along with Redux). Fortunately, the individual parts are small and easy to understand.
And... I want to help you understand. But rather than risk you rage-quit programming because of the (N+1)th TodoMVC tutorial, I've tried to put together a game-like tutorial to make learning Redux more fun. My goal is to teach you Redux and -- equally important -- make you aware of the awesome third-party libraries in the ecosystem.
This is Part 1 in the series.
Set up the Environment
Forget about React, Gulp, Webpack, Express, and every other crazy dependency. There is no need to set up a build pipeline and a web server with hot reloading, etc. (in game parlance, Nightmare mode). We're going to do this on super easy mode. We'll code directly in a JSFiddle sandbox.
- Go to https://jsfiddle.net
- Add an external resource: https://cdnjs.cloudflare.com/ajax/libs/redux/3.5.2/redux.min.js
- Change the language from "JavaScript" to "Babel"
- Open the console window (F12 or Ctrl+Shift+i)
Once upon a time...
Let's start with a very, very simple game. Not even really a game. We are going to start by writing a tiny program that lets you level up. We'll build on it from there. Take a minute to study the following program.
const { createStore } = Redux;//// Actions//const Actions = { LEVEL_UP: 'LEVEL_UP',};//// Action Creators//const levelUp = () => ({ type: Actions.LEVEL_UP});//// Reducers//const levelReducer = (state = 1, action) => { switch (action.type) { case Actions.LEVEL_UP: return state + 1; } return state;};//// Bootstrapping//const store = createStore(levelReducer);//// Run!//console.log(store.getState());store.dispatch(levelUp());console.log(store.getState());
Line 1
const { createStore } = Redux;
Import the createStore function from Redux. This style of assignment is called Destructuring. Also note that JSFiddle puts Redux in the global namespace. In a real app we'd write:
import { createStore } from 'redux';
Lines 6 - 8
const Actions = { LEVEL_UP: 'LEVEL_UP',};
Here we declare all the possible types of actions our game has. We are starting small and will... level up.
Lines 13 - 15
const levelUp = () => ({ type: Actions.LEVEL_UP});
This is called an 'action creator' in Redux. It's a function that creates a plain Javascript object that describes an action to take -- i.e. a state change. To make the action actually happen, we need to dispatch it to the store (see below).
Our level-up action doesn't take any parameters but we'll see examples of that later.
If you're new to ES6... here's what the ES5 Javascript equivalent would look like:
function levelUp() { return { type: Actions.LEVEL_UP };}
Lines 20 - 26
const levelReducer = (state = 1, action) => { switch (action.type) { case Actions.LEVEL_UP: return state + 1; } return state;};
'Reducer' is a fancy name for a function that, given the current state and an action, calculates the next state. In our example, the state is the current level (which defaults to 1). When our reducer processes the LEVEL_UP action, it will return an incremented state (i.e. level).
For reasons I will gloss over here, we must return the state untouched if our reducer ignored the action (line 25)
Line 31
const store = createStore(levelReducer);
Here we're using a method from Redux to transform our reducer into a 'store' object that will be the single source of state in our application. The notable methods on store are getState() and dispatch(). The most important thing to know about state is that it must be immutable (i.e. you are not allowed to change it directly). When you want something to be different in your app, you must dispatch an action to the store, which then gets internally passed to reducers.
Lines 36 - 38
console.log(store.getState());store.dispatch(levelUp());console.log(store.getState());
Output the current state, level up, and output the (new!) current state. You can also subscribe to the store to receive change notifications. Next time we'll use that rather than manually printing the state.
So... you now have a fully-functioning Redux application. Yaay.
But hey, you can write the same thing without Redux in 4 lines.
Well sure you can. But the example functionality is trivial. Let's add some complexity.
A Hero is Born
We'll make this a little more interesting by introducing our main character. In addition to having a level, our hero exists at some position in the world, has a concept of current health and maximum health, and some kind of capacity to carry items.
I've added these new elements to the following code. Take a few minutes to read it and try to understand what's happening. (The highlighted sections show the additions)
const { createStore, combineReducers } = Redux;const initialState = { xp: 0, level: 1, position: { x: 0, y: 0, }, stats: { health: 50, maxHealth: 50, }, inventory: { potions: 1, }};//// Actions//const Actions = { GAIN_XP: 'GAIN_XP', LEVEL_UP: 'LEVEL_UP', MOVE: 'MOVE', DRINK_POTION: 'DRINK_POTION', TAKE_DAMAGE: 'TAKE_DAMAGE',};//// Action Creators//const gainXp = (xp) => ({ type: Actions.GAIN_XP, payload: xp});const levelUp = () => ({ type: Actions.LEVEL_UP});const move = (x, y) => ({ type: Actions.MOVE, payload: { x, y }});const drinkPotion = () => ({ type: Actions.DRINK_POTION});const takeDamage = (amount) => ({ type: Actions.TAKE_DAMAGE, payload: amount});//// Reducers//const xpReducer = (state = 0, action) => { switch (action.type) { case Actions.GAIN_XP: return state + action.payload; } return state;};const levelReducer = (state = 1, action) => { switch (action.type) { case Actions.LEVEL_UP: return state + 1; } return state;};const positionReducer = (state = initialState.position, action) => { switch (action.type) { case Actions.MOVE: let { x, y } = action.payload; x += state.x; y += state.y; return { x, y }; } return state;};const statsReducer = (state = initialState.stats, action) => { let { health, maxHealth } = state; switch (action.type) { case Actions.DRINK_POTION: health = Math.min(health + 20, maxHealth); return { ...state, health, maxHealth }; case Actions.TAKE_DAMAGE: health = Math.max(0, health - action.payload); return { ...state, health }; } return state;};const inventoryReducer = (state = initialState.inventory, action) => { let { potions } = state; switch (action.type) { case Actions.DRINK_POTION: potions = Math.max(0, potions - 1); return { ...state, potions }; } return state;};//// Bootstrapping//const reducer = combineReducers({ xp: xpReducer, level: levelReducer, position: positionReducer, stats: statsReducer, inventory: inventoryReducer,});const store = createStore(reducer);store.subscribe(() => { console.log(JSON.stringify(store.getState()));});//// Run!//store.dispatch(move(1, 0));store.dispatch(move(0, 1));store.dispatch(takeDamage(13));store.dispatch(drinkPotion());store.dispatch(gainXp(100));store.dispatch(levelUp());
Here is the console output:
Ok, so still not a game yet. But we're moving towards the goal. The code already has a few problems (aside from not being a game yet). Stay tuned for the next episode where I discuss the problems and how to solve them. We'll also continue to add features to the 'game'.
Think you spot a problem or room for improvement? Leave a comment and let me know.
Links to other installments:
- Part 1: A Hero is Born (A Fun Introduction to Redux.js)
- Part 2: Actions and Their Consequences (a Fun Introduction to redux-actions)
- Part 3: Choose Wisely (a Fun Introduction to reselect.js)
- Part 4: Every Hero Needs a Villain (a Fun Introduction to redux-saga)
- Part 5: The Hero is Tested (a Fun Introduction to redux-saga-test-plan)