If you have your own github account already, you might prefer to fork this repository and clone that instead. If you don't just run the following commands on a terminal or command line interface (assuming that your machine already has git available):
git clone https://github.com/jenofdoom/react-intermediate.git
First, we need to install node.js and its package manager, npm.
Ubuntu/Debian/Mint instructions
If you have a favourite code editor feel free to use that, but I recommend Atom.
In Atom, right click in the left panel, select Add Project Folder
and open the
react-intermediate/tutorial
folder. The example
folder has a working version
of what we'll be building.
In a terminal:
cd react-intermediate/tutorial
npm install
npm start
Note that there are a lot of different ways of structuring and implementing
redux applications. The official
documentation offers a
bunch of different examples with source
code. The async
and
shopping-cart
examples are the closest to what we'll build today.
In a terminal in the tutorial folder:
npm install --save-dev redux redux-thunk redux-logger react-redux
In src/index.jsx
, add the Provider and store imports, and wrap the Router
in
a Provider
:
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { Provider } from 'react-redux';
import configureStore from 'store';
import 'index.scss';
import Homepage from 'components/homepage/homepage';
import About from 'components/about/about';
import Nav from 'components/nav/nav';
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<Router>
<div className="container-fluid">
<Nav />
<Route exact path="/" component={Homepage}/>
<Route path="/about" component={About}/>
</div>
</Router>
</Provider>,
document.getElementById('app')
);
Create a new file in the tutorial/src
folder called store.js
:
import { createStore, applyMiddleware } from 'redux';
import { createLogger } from 'redux-logger';
import thunk from 'redux-thunk';
import reducers from 'reducers/reducers';
export default function configureStore() {
const middleware = [ thunk ];
if (process.env.NODE_ENV !== 'production') {
middleware.push(createLogger());
}
return createStore(
reducers,
applyMiddleware(...middleware)
);
}
We're still importing things that don't yet exist, we'd better fix that:
Create a new folder, tutorial/src/reducers
with a file in it called
reducers.js
:
import { combineReducers } from 'redux';
const initialGameState = {};
const game = (state = initialGameState, action) => {
switch (action.type) {
default:
return state;
}
};
export default combineReducers({
game
});
We've only set up one reducer, called game
, but we've left our structure
flexible by using
combineReducers which means
we can break off sepearate chunks of state later, and potentiall even break
those out into separate files.
Right now the reducer only returns the initial state, as we aren't passing in anything which sets new values.
In reducers.js
, we want to actually set the initial state up with an array of
the values for each hole (exporting the amount of holes we intend to have so we
can use that number elsewhere later):
export const holesLength = 5;
const initialGameState = {
holeState: Array(holesLength).fill(false)
};
In homepage.jsx
at the top of the file, we should import redux's connection
function:
import { connect } from 'react-redux';
and export default Homepage;
at the bottom of the file becomes:
const mapStateToProps = (state) => {
const { game } = state;
const { holeState } = game;
return { holeState };
}
export default connect(mapStateToProps)(Homepage);
mapStateToProps
is where we unpack the data we recieve back from the reducer,
and set the props value that we care about up for the component to consume.
Now we're passing the data in, we should update our for
loop that sets up the
holes with a new property, active
:
for (let i = 0; i < this.props.holeState.length; i++) {
holes.push(<Hole key={'hole-' + i} id={i} active={this.props.holeState[i]} />);
}
Alternatively, we could have connected each hole
component individually to the
store, but as we have an array of values rather that an object, that doesn't
make much sense in this case.
We should also add in the missing propTypes for the prop we've just added - although it doesn't get passed in from the parent like a normal prop, it still is a prop:
Homepage.propTypes = {
holeState: PropTypes.array.isRequired
};
Don't forget to import PropTypes
at the top of the file:
import PropTypes from 'prop-types';
In a redux application, it is not necessary to connect every single component up to the store. We can still use normal methods of passing props between components where is makes sense to.
We can test that the store is connected to the component properly by changing
the number of the holes in the reducer (the value of holesLength
).
Now let's hook our new prop up in hole.jsx
- we need to delete some exisiting
stuff first because we no longer want to be using state to control if the frog
is active, and we shouldn't need our activate buttons any more either (we also
need to add PropTypes):
import React, { Component, } from 'react';
import PropTypes from 'prop-types';
import holeMask from 'assets/img/hole-mask.svg';
import './hole.scss';
class Hole extends Component {
render () {
let frogClass = 'frog';
if (this.props.active) {
frogClass = 'frog up';
}
return (
<div className="hole-container">
<div className="hole">
<div className={frogClass}></div>
<img src={holeMask} className='hole-mask' />
</div>
</div>
);
}
}
Hole.propTypes = {
active: PropTypes.bool.isRequired
};
export default Hole;
Now our component is connected (via its parent) to the store, if we change the
initialGameState
values we can set some frogs on manually. Test that that
works by setting the .fill()
in reducers.jsx
to set all the values to true
intitally (and then set it back again once you're happy that that works).
To start the game, we want the user to hit the start button, which will trigger one of the frogs popping up. When we want to trigger an action, we use a redux function called 'dispatch'.
Make a new file, actions/actions.js
:
export const START_GAME = 'START_GAME';
export const startGame = () => {
return {
type: START_GAME
};
};
It is not strictly speaking necessary to set up the action type as a const, but doing so ensures that we don't garble them as we need to use them both here and in the reducer, too. See the docs for more on this.
The startGame
function is an
action-creator
which must be invoked via a dispatch
operation.
If we had a bigger application it would probably be wise to split our actions up into groups of different files by functional area.
In controls.jsx
, first import the connect
function and your action:
import { connect } from 'react-redux';
import { startGame } from 'actions/actions';
then at the beginning of the startGame
method add a dispatch
of that action:
startGame () {
this.props.dispatch(startGame());
~~~etc~~~
and at the bottom of the file, we still need to hook up mapStateToProps
even
though we are only doing a dispatch not taking state back from the store:
Controls.propTypes = {
dispatch: PropTypes.func.isRequired
};
const mapStateToProps = () => {
return {};
};
export default connect(mapStateToProps)(Controls);
In reducers/reducers.js
, import the action type, add a new key value pair to
the initialGameState
, and add a case for the action type:
import { combineReducers } from 'redux';
import { START_GAME } from 'actions/actions';
export const holesLength = 5;
const initialGameState = {
holeState: Array(holesLength).fill(false),
isGameActive: false
};
const game = (state = initialState, action) => {
switch (action.type) {
case START_GAME:
return Object.assign({}, state, {
isGameActive: true
});
default:
return state;
}
};
export default combineReducers({
game
});
We can see from the console log that we are successfully manipulating the state, but we aren't doing anything with those state changes as yet. Before we get to that, let's first kick off our first frog popping up, as part of the start game action.
In actions/actions.js
:
import { holesLength } from 'reducers/reducers';
export const START_GAME = 'START_GAME';
export const ALTER_HOLES = 'ALTER_HOLES';
const startGame = () => {
return {
type: START_GAME
};
};
const alterHoles = (holeState) => {
return {
type: ALTER_HOLES,
holeState: holeState
};
};
const getRandomInt = (min, max) => {
return Math.floor(Math.random() * (max - min)) + min;
};
export const startGameAction = () => {
return (dispatch, getState) => {
dispatch(startGame());
let newState = getState().game.holeState.slice(0);
newState[getRandomInt(0, holesLength)] = true;
dispatch(alterHoles(newState));
};
};
Note that we had to create a new action (not action creator) here,
startGameAction
, to group togther our two dispatches plus some logic. The
action function has a weird structure - that's because we are using the
thunk middleware which
makes it so that we can delay the dispatch if we need to via an asynchronous
call (for example, for an API call), or so we can stick some logic in to make
the dispatch conditional.
In controls.jsx
, update the name of the action we call:
import { startGameAction } from 'actions/actions';
~~~etc~~~
startGame () {
this.props.dispatch(startGameAction());
~~~etc~~~
In reducers/reducers.js
let's add our new action type:
import { combineReducers } from 'redux';
import { START_GAME, ALTER_HOLES } from 'actions/actions';
export const holesLength = 5;
const initialGameState = {
holeState: Array(holesLength).fill(false),
isGameActive: false
};
const game = (state = initialGameState, action) => {
switch (action.type) {
case START_GAME:
return Object.assign({}, state, {
isGameActive: true
});
case ALTER_HOLES:
return Object.assign({}, state, {
holeState: action.holeState
});
default:
return state;
}
};
export default combineReducers({
game
});
Clicking on the start should now trigger one of the frogs.
Now we need a click action on the frogs that should trigger 1) hiding that frog
and 2) randomly popping up another. In actions/actions.js
:
export const clickFrogAction = (frogId) => {
return (dispatch, getState) => {
let newState = getState().game.holeState.slice(0);
newState[frogId] = false;
dispatch(alterHoles(newState));
setTimeout(
() => {
let newerState = newState.slice(0);
newerState[getRandomInt(0, holesLength)] = true;
dispatch(alterHoles(newerState));
},
700
);
};
};
You'll notice here that we perform a slice
operation whenever we get the
curent state. That's because we want a copy of the array rather that a reference
to it. Reducers in redux must have immutable state that gets overwritten
rather than mutated in order to properly trigger changes downstream.
In holes.jsx
we should import the action (which means we have to hook the
component up to connect
), and add back in a method for the click action on the
frog:
import React, { Component, } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import holeMask from 'assets/img/hole-mask.svg';
import { clickFrogAction } from 'actions/actions';
import './hole.scss';
class Hole extends Component {
constructor (props) {
super(props);
this.frogClick = this.frogClick.bind(this);
}
frogClick () {
this.props.dispatch(clickFrogAction(this.props.id));
}
render () {
let frogClass = 'frog';
if (this.props.active) {
frogClass = 'frog up';
}
return (
<div className="hole-container">
<div className="hole">
<div className={frogClass} onClick={this.frogClick}></div>
<img src={holeMask} className='hole-mask' />
</div>
</div>
);
}
}
Hole.propTypes = {
active: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
id: PropTypes.number.isRequired
};
const mapStateToProps = () => {
return {};
};
export default connect(mapStateToProps)(Hole);
That should take care of chaining together all the frogs appearing. Now we have a proper state store set up we can also navigate to the about page and back without losing the state.
Try:
- setting up a END_GAME action that resets all the frogs to down
- refactoring the controls component so the game timer is moved to actions
- setting up a score counter that increments on each click
- resetting the score to 0 on START_GAME
- making frogs dissapear automatically if they aren't clicked after an interval
React has no tooling of its own for AJAX, so you need to either use the browsers
native XMLHttpRequest
, another library, or (what I'd recommend) the modern replacement for
XMLHttpRequest
, which is called
fetch. Unfortunately
IE does not support fetch
(Edge does) so we need a polyfill if we want to
support IE. The popular polyfill for this is whatwg-fetch, but you'll also need a polyfill for promises too - babel-polyfill can take care of that requirement (both of these are already in place for the webpack build for this project).
A fetch chain looks something like:
fetch('http://example.com/api/')
.then(checkStatus())
.then(response => response.json())
.then(json => {
// do something with json here - dispatch an action?
})
.catch(error => {
// do something about your error here
});
where checkStatus
is a function that you'd always inset into fetch calls to catch error status codes where a response did get returned:
export function checkStatus (response) {
if (response.status >= 200 && response.status < 300) {
return response
} else {
var error = new Error(response.statusText)
error.response = response
throw error
}
}
You can put fetch chains inside your redux thunk actions in order to trigger dispatches once the promise is resolved.
Although there are man test frameworks that you could consider integrating with your React project, broadly speaking Jest is the most popular and well-integrated. We also use enzyme to make assertions about our components. We'll add some tests to one of our components to show how it works.
I have done this setup for you, but I'm documenting here what things had to be added to make Jest run
The Jest packages and some extra utils needed installing:
npm install --save-dev jest babel-jest enzyme react-addons-test-utils react-test-renderer
In package.json
some extra scripts were added:
"scripts": {
"test": "./node_modules/.bin/jest",
"test:watch": "./node_modules/.bin/jest --watch",
"test:coverage": "./node_modules/.bin/jest --coverage",
and also in package.json
a new key was added for the Jest
configuration:
"jest": {
"verbose": true,
"moduleDirectories": [
"src",
"node_modules"
],
"moduleNameMapper": {
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/jest-mock-files.js",
"^.+\\.(scss|css)$": "<rootDir>/jest-mock-styles.js"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!node_modules/**",
"!src/index.jsx",
"!src/store.jsx"
]
}
Two mock files were created in the project root:
jest-mock-files.js
:
module.exports = 'test-file-stub';
jest-mock-styles.js
:
module.exports = {};
A line was added to the .gitignore
file:
coverage
.babelrc
was modified (alternatively we could get
Webpack to run the tests
for us):
{
"presets": [
"react",
[
"env",
{
"targets": {
"uglify": true
},
"modules": false
}
]
],
"env": {
"test": {
"plugins": ["transform-es2015-modules-commonjs"]
}
}
}
Because of this setup we can now run the command line arguments:
npm run test
npm run test:watch
npm run test:coverage
But the first two commands will complain about there not being any tests as yet!
First, in example/src/components/hole/hole.jsx
change class Hole extends Component {
to export class Hole extends Component {
.
Add a new file example/src/components/hole/hole.test.js
:
import React from 'react';
import { Hole } from 'components/hole/hole';
import { shallow } from 'enzyme';
describe('initial render of Hole', () => {
let hole = null;
beforeEach(() => {
const dispatchMock = jest.fn();
const props = {
dispatch: dispatchMock,
active: false,
id: 1
};
hole = shallow(
<Hole {...props} />
);
})
test('Frog is hidden if Hole is not active', () => {
expect(hole.find('.frog').hasClass('up')).toBeFalsy();
});
});
Enzyme has serveral differnt types of rendering (shallow is what we use most commonly, but sometimes we might need mount). Each method has its own api, for example we use hasClass above and then state the outcome we expect using Jest's toBeFalsy. We also use a mock function from Jest to mock the dispatch prop.
A higher-order component, or HOC, is a way of having reusable bits of logic that you would use to encapsulate varied content - kind of like a decorator, but for components (or like Angular 1's concept of transclusion).
It's possible to achieve code reuse through the use of subclassing, but HOC's are more explict when you're trying to make reusable components.
This article examines HOCs in some detail.
There are a lot of different opinions on this topic and you will see projects that differ wildly. To a large extent it sort of depends on your project size... a very small project could be just one file.
I've found that subfolders for components plus their styles and tests works, with separate folders for other types of constructs like actions and reducers works well for medium sized projects. The example projecct for today uses this structure.
For a much larger projects, you should consider a fractal structure (see this discussion for some detail on what that entails) where the prjecct is split into self contained functional areas that contain all of their dependencies inline (so rather than having a actions folder at the root of the src tree, the one action file that you need for that piece is self contained there). react-redux-starter-kit partially implements this pattern.
An alternative to our Webpack process is create-react-app. let's eplore how that works:
cd ~
sudo npm install -g create-react-app
create-react-app test-app
cd test-app/
npm start
Now let's perform an eject to see what create-react-app is doing under the hood - you wouldn't normally perform this as it's one way until you absolutely had to but we're just having an explore.
npm run eject
Make sure that you use the production
build
of the React libraries. Practically speaking, if you are using webpack and the
-p
flag, you are sorted.
In your application itself, if there are things that would want to make conditional for development only (e.g. logging) you can wrap tham in a guard:
if (process.env.NODE_ENV !== 'production') {
// your conditional stuff here
}
React Native is:
- Proper native app code, not a webviews solution like Cordova
- For both iOS and Android
- A good developer environment - you get much faster development builds compared to e.g. Cordova
- A way of use native mobile APIs like the Camera
React Native is not:
- A magical way of making your web application into a mobile app with no effort
- Not perfectly cross-platform 100% of the time, e.g. you need a third party component react-native-datepicker to abstract the date picker widget between platforms
It is possible to resuse some of the application logic between a web project and a Native project, see this article for some tips. But you definitely can't reuse the render methods, and you might bump into issues with dependencies differing between the two, so you'd have to consider the pros and cons there.
NativeBase can take some of the pain out of creating cross-platform (iOS and Android) widgets.