Goal: get webpack and babel running
- Set up a new folder to work in
npm init
and press ENTER all the waynpm install --save-dev babel-core babel-loader babel-preset-es2015 eslint eslint-config-mailonline rimraf webpack webpack-dev-server
webpack
: to bundle our javascriptwebpack-dev-server
: to run a server on localhost which bundles and automatically refreshesbabel-core
andbabel-loader
: so that webpack can load js files through babelbabel-preset-es2015
: a rather broad babel preset, just to start with, will transpile a lot of stuffeslint
andeslint-config-mailonline
: to have some linting from the get-gorimraf
: to remove thedist
folder before building
- Create a
.gitignore
file with these contents:*.log *.swo *.swp *.lock .DS_Store .idea/ .scripts/ .vagrant/ .nyc_output/ .vscode/ dist/ coverage/ dev/ node_modules/
- Create
.eslintrc.json
with these contents:{ "extends": "mailonline", "root": true }
- Create
.babelrc
with these contents:{ "presets": ["es2015"] }
- Create
src/index.js
with the following contents:/* eslint-disable no-console */ console.log('Hello world!');
- Create
webpack.config.babel.js
with these contents:import path from 'path'; export default { devServer: { contentBase: [ 'demo/' ], host: '0.0.0.0', inline: true, publicPath: '/dist/', watchContentBase: true }, entry: { index: './src/index.js' }, module: { rules: [ { loader: 'babel-loader', test: /\.js$/ } ] }, output: { filename: '[name].js', path: path.join(__dirname, 'dist') } };
- Create a
demo
folder with anindex.html
file:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> <title>Dev server</title> </head> <body> <script src="/dist/index.js" defer></script> </body> </html>
- Modify your
package.json
scripts
section:"scripts": { "prebuild": "rimraf ./dist", "build": "webpack -p", "start": "webpack-dev-server", "lint": "eslint --ignore-path .gitignore '**/*.js'" }
- Run
npm run start
and openlocalhost:8080
(or whichever port webpack-dev-server gives you) in your browser, and you should seeHello world!
in your console. Changing the JS or the html will auto-refresh your browser tab - Run
npm run build
and you'll see your bundled script indist/index.js
Now you're ready for Step 2
Goal: add react and css modules
npm install --save-dev autoprefixer babel-preset-react css-loader node-sass postcss-loader prop-types react react-dom sass-loader style-loader stylelint stylelint-config-standard
react
andreact-dom
: to use reactsass-loader
andnode-sass
: for webpack to load SCSS files and convert them into CSScss-loader
: for webpack to load CSS files and, in our case, to parse them into CSS modulesstyle-loader
: for webpack to bundle CSS into our javascript, and inline it into the page on demand (to extract all CSS into an external CSS file, you'd need to use ExtractTextPlugin)stylelint
andstylelint-config-mailonline
: to lint our CSSbabel-preset-react
: to add some plugins to our babel config, especially to transpile JSX syntaxautoprefixer
andpostcss-loader
: to post-process CSS, adding vendor prefixes to our rules
- Add
babel-preset-react
to our babel config in.babelrc
:{ "presets": ["es2015", "react"] }
- Create
.stylelintrc.json
:{ "extends": "stylelint-config-standard" }
- Modify
.eslintrc.json
to addmailonline/react
:{ "extends": ["mailonline", "mailonline/react"], "root": true }
- Create
postcss.config.js
(don't worry too much about it right now):/* eslint-disable import/no-commonjs, import/unambiguous, filenames/match-regex */ const autoprefixer = require('autoprefixer'); module.exports = { plugins: [ autoprefixer({ browsers: [ '> 1%', 'last 3 versions', 'iOS > 8', 'not ie < 10' ] }) ] };
- Modify your
module -> rules
array inwebpack.config.babel.js
to add a loader for.scss
files:... module: { rules: [ { loader: 'babel-loader', test: /\.js$/ }, { test: /\.scss$/, use: [ 'style-loader?sourceMap', { loader: 'css-loader', options: { importLoaders: 3, localIdentName: '[local]__[hash:base64:5]', modules: true, sourceMap: true } }, 'postcss-loader?sourceMap', 'sass-loader?sourceMap' ] } ] }, ...
- Replace your
lint
script inpackage.json
with this:"lint-scripts": "eslint --ignore-path .gitignore '**/*.js'", "lint-styles": "stylelint --ignore-path .gitignore '**/*.{scss,sass,css}'", "lint": "npm run lint-scripts && npm run lint-styles && npm run lint-json",
- Create
src/styles.scss
:.container { padding: 16px; background: #eee; border: 1px solid black; }
- Modify
src/index.js
:import React from 'react'; import {render} from 'react-dom'; import styles from './styles.scss'; render( <div className={styles.container}>Hello world!</div>, document.querySelector('[data-react-workshop]') );
- Modify
demo/index.html
, add this before thescript
element, in the body:<div data-react-workshop></div>
- Run
npm run start
and openlocalhost:8080
(or whichever port applies), you should see a grey box with a black border and "Hello world!" inside
Goal: build our actual app with redux and redux-saga
- `npm install --save-dev babel-plugin-transform-async-to-generator babel-plugin-transform-runtime babel-preset-stage-3 react-redux redux redux-saga whatwg-fetch`
- `babel-plugin-transform-async-to-generator`: to use async/await
- `babel-plugin-transform-runtime`: in a real production environment we'd only use this for tests, and babel-preset-env + useBuiltIns + babel-polyfill for the actual bundle, but for simplicity's sake we'll just use this one here on its own - please do look into babel-preset-env
- `babel-preset-stage-3`: for object rest spread (`{...}`)
- `react-redux`: to connect redux state to react components
- `redux-saga`: to handle side effects in our app (e.g. fetching from the github API)
- Add `stage-3` preset and `transform-runtime` plugin to our `.babelrc` config:
```json
{
"presets": ["es2015", "stage-3", "react"],
"plugins": [
["transform-runtime", {
"helpers": false,
"polyfill": false,
"regenerator": true
}]
]
}
```
- Start with the action creators - create `src/actions/repos.js`:
```js
export const types = {
ERROR_REQUESTING_REPOS: 'ERROR_REQUESTING_REPOS',
RECEIVED_REPOS: 'RECEIVED_REPOS',
REQUEST_REPOS: 'REQUEST_REPOS'
};
export const errorRequestingRepos = (error) => ({
payload: {
error
},
type: types.ERROR_REQUESTING_REPOS
});
export const receivedRepos = (repos) => ({
payload: {
repos
},
type: types.RECEIVED_REPOS
});
export const requestRepos = (organization) => ({
payload: {
organization
},
type: types.REQUEST_REPOS
});
```
- Now the reducer - create `src/reducers/repos.js`:
```js
import {types} from '../actions/repos';
const {
ERROR_REQUESTING_REPOS,
RECEIVED_REPOS,
REQUEST_REPOS
} = types;
export const initialState = {
error: null,
fetching: false,
repos: null
};
export default function repos (state = initialState, action) {
switch (action.type) {
case ERROR_REQUESTING_REPOS:
return {
...state,
error: action.payload.error
};
case RECEIVED_REPOS:
return {
...state,
fetching: false,
repos: action.payload.repos
};
case REQUEST_REPOS:
return {
...state,
fetching: true,
repos: null
};
default:
return state;
}
}
```
- And a root reducer which won't make much sense here, but would combine multiple ones in a real app - create `src/reducers/index.js`:
```js
import {combineReducers} from 'redux';
import repos from './repos';
const reducers = combineReducers({
repos
});
export default reducers;
```
- Add a `fetch` polyfill to our app - modify `src/index.js` and add this to the very top:
```js
// eslint-disable-next-line import/no-unassigned-import
import 'whatwg-fetch';
```
- Let's create an API for fetching the repos - create `src/api/repos.js`:
```js
export const fetchByOrg = async (organization) => {
const endpoint = `https://api.github.com/orgs/${organization}/repos`;
const response = await fetch(endpoint);
const json = await response.json();
if (response.status < 200 || response.status >= 400) {
const error = new Error(json && json.message || response.statusText);
error.response = response;
throw error;
}
return json.map(
// eslint-disable-next-line id-match
({name, html_url}) => ({
name,
url: html_url
})
);
};
```
- Now it's time to create our saga - create `src/sagas/repos.js`:
```js
import {call, fork, put, takeLatest} from 'redux-saga/effects';
import {fetchByOrg} from '../api/repos';
import {
errorRequestingRepos,
receivedRepos,
types
} from '../actions/repos';
export const fetchReposFromApi = function *(action) {
try {
const {payload: {organization}} = action;
const reposFromApi = yield call(fetchByOrg, organization);
yield put(receivedRepos(reposFromApi));
} catch (error) {
yield put(errorRequestingRepos(error));
}
};
const watchRequestRepos = function *() {
yield takeLatest(types.REQUEST_REPOS, fetchReposFromApi);
};
export default function *repos () {
yield [
fork(watchRequestRepos)
];
}
```
- Next, we create a root saga, much like the root reducer - create `src/sagas/index.js`:
```js
import {fork} from 'redux-saga/effects';
import repos from './repos';
export default function *sagas () {
yield [
fork(repos)
];
}
```
- The only thing left for our redux side of the app is, first, creating a store - create `src/store/configureStore.js`:
```js
import {applyMiddleware, createStore, compose} from 'redux';
import createSagaMiddleware from 'redux-saga';
import rootReducer from '../reducers';
import rootSaga from '../sagas';
export default function configureStore (preloadedState) {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer,
preloadedState,
compose(
applyMiddleware(sagaMiddleware),
window && window.devToolsExtension ?
window.devToolsExtension() :
(passThrough) => passThrough
)
);
sagaMiddleware.run(rootSaga);
return store;
}
```
- And, finally, wiring it into our app via the `react-redux` `Provider` HOC - modify `src/index.js`:
```js
// eslint-disable-next-line import/no-unassigned-import
import 'whatwg-fetch';
import React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import configureStore from './store/configureStore';
import styles from './styles.scss';
const store = configureStore();
render(
<Provider store={store}>
<div className={styles.container}>Hello world!</div>
</Provider>,
document.querySelector('[data-react-workshop]')
);
```
We now have a perfectly workable Redux state tree, with sagas running... except we have no UI components to: 1) Dispatch the action that will trigger the saga that fetches the repos, and 2) Display the information conveyed by the state tree to the user
So let's create some components to do just that.
- Create `src/components/GetReposButton/index.js`:
```js
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {requestRepos} from '../../actions/repos';
import styles from './styles.scss';
const HARDCODED_ORG_NAME = 'github';
const GetReposButton = ({dispatch, fetching}) => {
const handleOnClick = () => dispatch(requestRepos(HARDCODED_ORG_NAME));
return (
<button
className={styles.button}
disabled={fetching}
onClick={!fetching && handleOnClick}
>
Get Repos
</button>
);
};
GetReposButton.propTypes = {
dispatch: PropTypes.func.isRequired,
fetching: PropTypes.bool.isRequired
};
const mapStateToProps = (state) => ({
fetching: state.repos.fetching
});
export {GetReposButton as GetReposButtonPureComponent};
export default connect(mapStateToProps)(GetReposButton);
```
- And its corresponding `src/components/GetReposButton/styles.scss`:
```css
.button {
background: #4cf;
font-weight: bold;
border: 0;
appearance: none;
padding: 16px;
&[disabled] {
background: #999;
color: white;
}
}
```
- Now to display some results - create `src/components/RepoList/index.js`:
```js
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
const RepoList = ({fetching, repos}) => {
if (fetching) {
return <p>Loading...</p>;
}
if (!repos) {
return <p>No results</p>;
}
return (
<ul>
{
repos.map(
({name, url}) =>
<li key={name}>
<a href={url}>{name}</a>
</li>
)
}
</ul>
);
};
RepoList.propTypes = {
fetching: PropTypes.bool.isRequired,
repos: PropTypes.array
};
RepoList.defaultProps = {
repos: null
};
const mapStateToProps = (state) => ({
fetching: state.repos.fetching,
repos: state.repos.repos
});
export {RepoList as RepoListPureComponent};
export default connect(mapStateToProps)(RepoList);
```
- And now we just wire them together into the root app component - modify `src/index.js`:
```js
// eslint-disable-next-line import/no-unassigned-import
import 'whatwg-fetch';
import React from 'react';
import {render} from 'react-dom';
import {Provider} from 'react-redux';
import GetReposButton from './components/GetReposButton';
import RepoList from './components/RepoList';
import configureStore from './store/configureStore';
import styles from './styles.scss';
const store = configureStore();
render(
<Provider store={store}>
<div className={styles.container}>
<GetReposButton />
<RepoList />
</div>
</Provider>,
document.querySelector('[data-react-workshop]')
);
```
- Run `npm run start` and open `localhost:8080` (or whichever port) - click on the button
- Congratulations! You should have a fully running react/redux/redux-saga application there, unless something went horribly wrong.
Keep in mind that there's millions of equally valid ways to create a react/redux application - the flexibility offered by these libraries is immense. You'll have to experiment and pick and choose to find what works for you.
Now, no application is complete without tests - so let's see to that in Step 4
Goal: add tests with jest
In these kinds of apps, it's a good idea to make components as dumb as possible - they take in state, and dispatch actions. Some inevitable exceptions will arise, but the general rule should be to keep them as simple as possible. The real meat-and-bones will be in the sagas, actions and reducers - so our focus while testing should be on those.
I'll just provide example tests for the main saga, the reducer, and the components (using both enzyme and jest snapshots to showcase both approaches, which are not exclusive at all).
- `npm install --save-dev babel-jest jest identity-obj-proxy enzyme react-test-renderer`
- `babel-jest`: for jest to run js files through babel
- `jest`: to run our test suite
- `identity-obj-proxy`: we're not going through webpack, so we need something to replace CSS modules - this is just a proxy that, when trying to access any property, will return the key as a string
- Modify your `package.json`, adding a new `jest` section, and a `test` script in `scripts`:
```json
{
...
"scripts": {
...
"test": "jest"
},
...
"jest": {
"collectCoverageFrom": [
"src/**/*.js"
],
"moduleNameMapper": {
"\\.(scss)$": "identity-obj-proxy"
},
"transform": {
"^.+\\.js$": "babel-jest"
}
}
}
```
- Create `test/.eslintrc.json` to add some jest-specific rules:
```json
{
"extends": "mailonline/jest"
}
```
- Create `test/sagas/repos.spec.js`:
```js
import {call, put} from 'redux-saga/effects';
import {errorRequestingRepos, receivedRepos, requestRepos} from '../../src/actions/repos';
import {fetchByOrg} from '../../src/api/repos';
import {fetchReposFromApi} from '../../src/sagas/repos';
describe('repo sagas', () => {
describe('fetchReposFromApi', () => {
it('puts receivedRepos action after fetching successfully', () => {
const orgName = 'github';
const requestAction = requestRepos(orgName);
const mockResults = [
{
name: 'somerepo',
url: 'http://github.com/github/somerepo'
}
];
const iterator = fetchReposFromApi(requestAction);
expect(iterator.next().value).toEqual(call(fetchByOrg, orgName));
expect(iterator.next(mockResults).value).toEqual(put(receivedRepos(mockResults)));
expect(iterator.next().done).toBe(true);
});
it('puts error action if requesting repos failed', () => {
const orgName = 'github';
const requestAction = requestRepos(orgName);
const error = new Error('error fetching repos');
const iterator = fetchReposFromApi(requestAction);
expect(iterator.next().value).toEqual(call(fetchByOrg, orgName));
expect(iterator.throw(error).value).toEqual(put(errorRequestingRepos(error)));
expect(iterator.next().done).toBe(true);
});
});
});
```
- Create `test/reducers/repos.spec.js`:
```js
import {
errorRequestingRepos,
receivedRepos,
requestRepos
} from '../../src/actions/repos';
import reposReducer, {initialState} from '../../src/reducers/repos';
describe('repos reducer', () => {
const mockRepos = [
{
name: 'reponame',
url: 'http://github.com/org/reponame'
}
];
it('error action sets error', () => {
const error = new Error('error fetching');
const errorAction = errorRequestingRepos(error);
expect(reposReducer(initialState, errorAction).error).toBe(error);
});
it('receivedRepos sets repos and stops loading', () => {
const initialStateWithLoading = {
...initialState,
loading: true
};
const receiveAction = receivedRepos(mockRepos);
const newState = reposReducer(initialStateWithLoading, receiveAction);
expect(newState.loading).toBe(false);
expect(newState.repos).toBe(mockRepos);
});
it('requestRepos starts loading and empties results', () => {
const orgName = 'github';
const stateWithResults = {
...initialState,
loading: true,
repos: mockRepos
};
const requestAction = requestRepos(orgName);
const newState = reposReducer(stateWithResults, requestAction);
expect(newState.loading).toBe(true);
expect(newState.repos).toBe(null);
});
});
```
- Create `test/components/GetReposButton.spec.js` (using enzyme):
```js
import React from 'react';
import {shallow} from 'enzyme';
import {GetReposButtonPureComponent as GetReposButton} from '../../src/components/GetReposButton';
import {requestRepos} from '../../src/actions/repos';
const HARDCODED_ORG_NAME = 'github';
describe('<GetReposButton/>', () => {
it('disables button while fetching', () => {
const dispatch = jest.fn();
const component = shallow(
<GetReposButton dispatch={dispatch} fetching={true} />
);
expect(component.find('button').prop('disabled')).toBe(true);
component.simulate('click');
expect(dispatch).not.toHaveBeenCalled();
});
it('dispatches requestRepos on click when not disabled', () => {
const dispatch = jest.fn();
const component = shallow(
<GetReposButton dispatch={dispatch} fetching={false} />
);
expect(component.find('button').prop('disabled')).toBe(false);
component.simulate('click');
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(requestRepos(HARDCODED_ORG_NAME));
});
});
```
- Create `test/components/RepoList.spec.js` (using snapshots):
```js
import React from 'react';
import renderer from 'react-test-renderer';
import {RepoListPureComponent as RepoList} from '../../src/components/RepoList';
const mockRepos = [
{
name: 'firstrepo',
url: 'https://github.com/orgname/firstrepo'
},
{
name: 'secondrepo',
url: 'https://github.com/orgname/secondrepo'
}
];
describe('<RepoList/>', () => {
it('renders "Loading" while fetching', () => {
const tree = renderer.create(<RepoList fetching={true} />).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders "No results" when repos prop is null', () => {
const tree = renderer.create(<RepoList fetching={false} repos={null} />).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders repo list with results when not fetching', () => {
const tree = renderer.create(<RepoList fetching={false} repos={mockRepos} />).toJSON();
expect(tree).toMatchSnapshot();
});
});
```
- Get a coverage report with `npm run test -- --coverage`
That's it! I hope you enjoyed the workshop. Feel free to contact me if you have any questions, or create an issue in this repository.