Web App¶
Design¶
Start with paper
See Describing UI state with data for a methodical approach to UI design
Drawn out the UI with pen and paper
Make a list of ‘What the user can see’, this is the
stateof the appMake a list of ‘What the user can do’, these are the
state transitionsof the appSeparate anything in the
statethat can be derived from something else, these are theprojections
State Model¶
Use Redux Toolkit
npm install --save-exact @reduxjs/toolkit redux-logger
Create state/index.js then for each:
piece of core
state: create a slice and an initial statestate transition: create a reducer functionprojection: create a selector
import { createAction, createSlice } from "@reduxjs/toolkit";
import { combineReducers } from "redux";
/*
REDUCERS
*/
const fooSlice = createSlice({
name: "foo",
initialState: { },
reducers: {
action1(state, action) {
state.bar = action.payload.bar;
// ...
},
// ...
});
export const { action1 } = fooSlice.actions;
// ...
export const rootReducer = combineReducers({
foo: fooSlice.reducer,
// ...
});
/*
SELECTORS
*/
export function selector1(state) {
return state.foo;
}
// ...
In index.js import the rootReducer and setup the App:
import React from "react"
import ReactDOM from "react-dom"
import { configureStore, getDefaultMiddleware } from "@reduxjs/toolkit"
import logger from "redux-logger"
import { Provider} from "react-redux"
import { rootReducer } from "src/state"
import App from "src/views/App"
const store = configureStore({
reducer: rootReducer,
middleware: [...getDefaultMiddleware(), logger],
});
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
redux-toolkit handles state transitions behind the scenes by using Immer. If you need to do mutations in order to
derive something from the state you can use it directly:
npm install --save-exact @reduxjs/toolkit redux-logger
import { produce } from "immer";
const derivedState = produce(state, draft => {
// Mutate draft
});
Controlled Components¶
Use React Redux
Use
connectto map application state and state transition methods to propsUse
autoBindin the constructor to make sure DOM handlers have access tothisExport both the connected and unconnected component to facilitate unit testing
npm install --save-exact react-redux react-autobind
Example:
import React from "react";
import { connect } from "react-redux";
import { selector1, selector2, action1, action2 } from "src/state";
import autoBind from "react-autobind";
export class Component extends React.Component {
constructor(props) {
super(props);
this.state = {};
autoBind(this);
}
handleChange(event) {
this.setState({foo: event.target.value});
}
handleClick() {
this.props.action1({value: this.state.foo});
}
render() {
return (
<div className="Component">
<div>{this.prop1}</div>
<input type="text" onChange={this.handleChange} />
<button onClick={this.handleClick}>Click</button>
</div>
);
}
}
export default connect((state, props) => {
return {
prop1: selector1(state),
prop2: selector2(state),
// ...
}
}, { action1, action2 })(Component);
Routes¶
Use React DOM Router
Setup routes in App.js using react-router-dom:
npm install --save-exact react-router-dom
import React from "react";
import { Switch, Route } from "react-router-dom";
import Home from "./Home";
import Foo from "./Foo";
class App extends React.Component {
render() {
return (
<div className="App">
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/foo/:id" render={(props) => {
return <Foo id={props.match.params.id} />
}} />
</Switch>
</div>
);
}
}
export default App;
In index.js wrap the App component in a Router:
import { BrowserRouter } from "react-router-dom";
// ...
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById("root")
);
Async Operations¶
Use Redux Saga
Separate async operations into layers:
Views make requests to trigger async operations and render progress, error, or success UIs based on the app state
Middleware watches for requests, fires off api calls, and makes state transitions based on the results
API code performs the actual calls to external services
Create a separate api module to perform the async calls:
export default class API {
_fetch(url, options) {
return fetch(process.env.REACT_APP_API_HOST + url, Object.assign({
// Set options common to all your API calls here
headers: {
"Content-Type": "application/json"
}
}, options)).then(response => {
if (!response.ok) {
throw Error(response.statusText);
}
return response.json();
});
}
apiMethod(args) {
return this._fetch("/v1/endpoint");
}
};
And have an instance available in src/state/index.js:
import API from "src/api";
const api = new API();
Use variables in .env.development, env.production, .env.test to control the location of the API in different
environments:
REACT_APP_API_HOST=http://localhost:5000
Create a slice in your app state dedicated to tracking the flow of async operations along with a getAsync selector:
const asycnRequestsSlice = createSlice({
name: "async",
initialState: {},
reducers: {
start(state, action) {
state[action.payload.id] = { inProgress: true };
},
error(state, action) {
state[action.payload.id] = {
inProgress: false,
error: action.payload.error
};
},
success(state, action) {
state[action.payload.id] = {
success: true,
response: action.payload.response
};
}
}
});
export const rootReducer = combineReducers({
async: asycnRequestsSlice.reducer,
// ...
});
export function getAsync(state, id) {
return state.async[id] || {};
}
Create a standalone action to triggering each async operation. The action payload should include at least an id
so that the operation can be tracked:
export const someAsyncRequest = createAction("someAsyncRequest");
Install Redux Saga:
npm install --save-exact redux-saga
Create a rootSaga for handling the async requests:
import { put, call, takeLatest } from "redux-saga/effects";
// ...
export const rootSaga = function*() {
yield takeLatest(someAsyncRequest.type, handleSomeAsyncRequest);
// ...
};
The handlers should follow the same basic pattern:
Do a state transition to indicate that the operation has begun
Make an api call to actually perform the operation
Wait for the api call to complete and do either a success or error state transition for the operation
const handleSomeAsyncRequest = function*(action) {
const {id} = action.payload
try {
yield put(asyncRequestsSlice.actions.start({ id }));
const response = yield call([api, "apiMethod"], apiArg1, apiArg2);
yield put(someStateTransitionBasedOnTheResponse({ response }));
yield put(asyncRequestsSlice.actions.success({ id, response }));
} catch (error) {
yield put(asyncRequestsSlice.actions.error({ id, error: error.toString() }));
}
};
Finally, hook up the root saga in index.js:
import createSagaMiddleware from "redux-saga"
import { configureStore, getDefaultMiddleware } from "@reduxjs/toolkit"
import logger from "redux-logger"
import { rootReducer, rootSaga } from "src/state"
const sagaMiddleware = createSagaMiddleware();
const store = configureStore({
reducer: rootReducer,
middleware: [...getDefaultMiddleware(), sagaMiddleware, logger],
});
sagaMiddleware.run(rootSaga);
Controlled Components can then use async actions and the getAsync selector to alter rendering based on the state of
the async operations:
import React from "react";
import { connect } from "react-redux";
import { someAsyncRequest, barAsyncRequest, getAsync } from "src/state"
class Edit extends React.Component {
componentDidMount() {
this.props.someAsyncRequest({id: this.props.requestId});
}
render() {
if (this.props.request.inProgress) {
return <div>Loading</div>;
} else if (this.props.request.error) {
return <div>{"Some error: " + this.props.request.error}</div>;
} else {
return <div>Request Complete</div>;
}
}
}
export default connect((state, props) => {
return {
request: getAsync(state, props.requestId),
}
}, {someAsyncRequest})(Edit);
Style¶
Use scss
Use Bootstrap
Use React Bootstrap
Co-locate
.scssfiles with Views so that the style can be imported withimport ./View.scssThe top-level tag in a Component should have
className="Component"and the scss should begin with.Component {to limit scopeInclude a
mixins.scsswith common helpers
npm install --save-exact bootstrap node-sass react-bootstrap
From index.js do import './index.scss' then in index.scss:
@import "variables";
@import "~bootstrap/scss/bootstrap";
/* Global Styles */
body {
}
variables.scss should include any bootstrap variable overrides:
$primary: #FF0000;
$accent: #00FF00;
$black: #222;
Folder Structure¶
Co-locate tests with source files
Co-locate styles with views
public/
src/
api/
index.js
api.test.js
assets/
foo.svg
...
state/
index.js
state.test.js
views/
App.js
App.scss
App.test.js
View2.js
View2.scss
View2.test.js
...
index.js
index.scss
setupTests.js
someConstants.js
...
package.json
README.md
...
Provides a clean separation between the main layers of the app. Files in views will depend on files in state which will
depend on files in api but ideally never in the reverse direction. Feature folders can be added to organize larger projects,
either at the top-level:
src/
feature1/
api/
assets/
state/
views/
feature2/
api/
assets/
state/
views/
...
Or within a given layer:
api/
assets/
state/
views/
feature1/
feature2/
...
If the top-level api/index.js/state/index.js files get too large they can be split into logical chunks, e.g.
state/additional.js:
export function someAdditionalSelector(state) { /* ... */ }
Then re-export the function in state/index.js so that consumers don’t need to be aware of the re-organization:
import { someAdditionalSelector } from "./additional";
// ...
export { someAdditionalSelector }
For imports across directories allow relative imports in jsconfig.json:
{
"compilerOptions": {
"baseUrl": "."
}
}
Then import using from 'src/<module>'. Always include src unless it’s a relative import from the same directory,
e.g. from './<module> to differentiate app modules from external packages.
Unit Tests¶
Use Jest
Both React and Redux encourage building components that leverage pure, stateless functions. The same methodology should extend to unit testing, ideally in a way that approaches Table Driven Tests as much as possible.
Testing Controlled Components¶
Given: a set of properties and a set of user interactions
Expect: a particular rendered HTML and a set of resulting calls on property methods
Use Jest Snapshot Testing for assertions against rendered HTML
Use React Testing Library for firing events
Don’t mock the redux store, bypass it by testing on the unconnected view (See this for motivation)
Don’t use shallow rendering (See this for motivation)
Basic Pattern:
import React from "react";
import { Component } from "./Component";
import { render, fireEvent } from "@testing-library/react";
// Necessary for ChildComponents that depend on being connected to the Redux store,
// if you find you have a lot of these statements it may be an indication that you should
// refactor them into Presentational Components and pass the state down from the parent
// as props
//
// Alternatively these can be defined in views/__mocks__/ChildComponent.js
jest.mock("./ChildComponent", () => ({
__esModule: true,
default: () => <div>Mocked Child Component</div>
}));
// Mock every action from `mapDispatchToProps` in the Component's connect call
const action1Mock = jest.fn();
const action2Mock = jest.fn();
// Testing helper to include defaults that particular cases don't care about
// and to hook up the mocks
const renderComponent = function({
prop1 = "prop1 default",
prop2 = "prop2 default"
}) {
// Additional requirements for rendering can be handled here such as wrapping in a <Router>
return render(
<Component
prop1={prop1}
prop2={prop2}
action1={action1Mock}
action2={action2Mock}
/>
);
};
const renderTest = options =>
expect(renderComponent(options).asFragment()).toMatchSnapshot();
// Enumerate various rendering cases based on the value of the Component's props
// Snapshots are committed into source control and should be checked on code review to
// make sure diffs correspond to appropriate Component changes
test("Rendering for when prop1 is foo", () => renderTest({ prop1: "foo" }));
test("Rendering for when prop2 is bar", () => renderTest({ prop1: "bar" }));
// ...
// Tests involving user interactions
test("Clicking the CTA", () => {
const component = renderComponent({});
fireEvent.click(component.getByText("CTA"));
expect(action1Mock.mock.calls).toEqual([[{ action: "foo" }]]);
});
To avoid test order issues, set jest to automatically clear mocks after each test in package.json:
"jest": {
"clearMocks": true
},
Presentational components can be tested in the same pattern. Each View should be tested but simpler components may only need a couple cases:
import React from "react";
import Simple from "./Simple";
import { render } from "@testing-library/react";
test("Render state when foo is on", () => {
expect(render(<Simple foo={true} />).asFragment()).toMatchSnapshot();
});
test("Render state when foo is off", () => {
expect(render(<Simple foo={false} />).asFragment()).toMatchSnapshot();
});
Or just a smoke test:
import React from "react";
import Simple from "./Simple";
import { render } from "@testing-library/react";
test("Basic rendering", () => {
expect(() => render(<Simple />)).not.toThrow();
});
Note
One thing that isn’t tested with this approach is the connect call itself. This function can be tested in isolation
but ideally there shouldn’t be much logic there and if there is it should probably be moved down into the selectors.
Note
At the moment snapshot testing seems to me to be a good approach for asserting against rendered HTML, however it is somewhat controversial, see the following for further discussion:
Testing Routes¶
Given: a route
Expect: a component to be rendered with particular props
Create a mock for each Component rendered under the App’s routes in views/__mocks__/Component.js:
import React from "react"
export const MockComponent = jest.fn();
MockComponent.mockReturnValue(<div>Mocked Component</div>);
const mock = jest.fn().mockImplementation(MockComponent);
export default mock
Basic Pattern:
import React from "react";
import { MemoryRouter} from "react-router-dom"
import { render } from "@testing-library/react";
import { MockComponent2 } from "./Component2";
import App from "./App";
jest.mock("./Component1");
jest.mock("./Component2");
const renderComponent = function(route) {
return render(
<MemoryRouter initialEntries={[route]}>
<App />
</MemoryRouter>
);
};
test("Default route", () => {
expect(renderComponent("/")).toMatchSnapshot();
});
test("Some route with params", () => {
expect(renderComponent("/route/foo/bar")).toMatchSnapshot();
expect(MockComponent2.mock.calls).toEqual([[{ p1: "foo", p2: "bar" }, {}]]);
});
Testing Selectors¶
Given: a state
Expect: a projection
Define an initial state to simplify testing basic scenarios
Use Immer to produce readonly state objects to mimic what the selectors would actually receive
Basic Pattern:
import { selector1, selector2 } from "./index.js"
import { produce } from "immer";
const initialState = produce({}, () => ({
foo: "bar",
baz: true,
}));
test("selector1", () => {
expect(selector1(initialState)).toBe("bar");
});
test("selector2 true", () => {
expect(selector2(initialState)).toBe(true);
});
test("selector2 false", () => {
const state = produce(initialState, draft => {
draft.baz = false;
});
expect(selector2(initialState)).toBe(false);
});
Testing Actions¶
Given: a state and a requested state transition
Expect: a final state
Define an initial state to simplify testing basic scenarios
Use your own selectors to verify updated state
Basic Pattern:
import { rootReducer, action1, selector1 } from "./index.js"
import { produce } from "immer";
const initialState = produce({}, () => ({
foo: "bar",
baz: true,
}));
test("action1", () => {
const updatedState = rootReducer(initialState, action1({ /* payload */ }));
expect(selector1(updatedState)).toBe("bar after action is applied");
});
Testing Async Operations¶
Given: a state, a requested async operation, and a set of mocked API responses
Expect: a final state
Actually run the saga
Use selectors to verify state after the async operation is performed
npm install --save-exact redux-saga-tester
Setup the API mock in api/__mocks__/index.js:
export const mockApiMethod1 = jest.fn();
export const mockApiMethod2 = jest.fn();
// ...
const mock = jest.fn().mockImplementation(() => ({
apiMethod1: mockApiMethod1,
apiMethod2: mockApiMethod2,
// ...
}));
export default mock;
Basic Pattern
import { someAsyncRequest, rootReducer, rootSaga, selector1 } from "./index";
import SagaTester from "redux-saga-tester";
import { mockApiMethod1 } from "src/api";
jest.mock("src/api");
const startTest = (action, initialState) => {
const sagaTester = new SagaTester({
initialState,
reducers: rootReducer
});
sagaTester.start(rootSaga);
sagaTester.dispatch(action);
return sagaTester;
};
test("someAsyncRequest success", async () => {
mockApiMethod1.mockResolvedValue({ /* response */ });
const sagaTest = startTest(someAsyncRequest({ /* payload */ }));
await sagaTest.waitFor("someAsyncRequest/success");
expect(selector1(sagaTest.getState())).toEqual(/* expectation */);
});
Testing API Calls¶
Given: an API method invocation
Expect: a network call
Use jest-fetch-mock
npm install --save-exact jest-fetch-mock
Add to setupTests.js:
global.fetch = require('jest-fetch-mock')
Basic pattern:
import API from "./index";
const api = new API();
test("Some fetch", async () => {
// ...
fetch.once(JSON.stringify({ /* response */ }));
const result = await api.apiMethod(/* apiArgs */);
expect(result).toEqual(/* expectedResult */);
expect(fetch.mock.calls).toEqual([
/* [expectedUrl, expectedFetchOptions] */
]);
});
Code Style¶
Use Prettier
Use ESLint
Use Husky
Use lint-staged
npm install --save-exact husky lint-staged prettier
Setup a pre-commit hook in package.json to automatically run Prettier and ESLint:
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{css,scss,md}": [
"prettier --write",
"git add"
],
"src/**/*.{js,jsx,json}": [
"eslint",
"prettier --write",
"git add"
]
},
This will only run Prettier on staged files going forward, so you should also do an initial Prettier run across all source files.
Add a pretty npm run command to package.json:
"scripts": {
"pretty": "prettier --write src/**/*.{js,jsx,json,css,scss,md}"
},
And run it:
npm run pretty
Adding Dependencies¶
Add new dependencies using
npm install --save-exact <package>No point in using
devDependenciesif the package is not a libraryUse
--save-exactto avoid deployment bugs when a minor or patch dependency version update breaks compatibility
Deployment¶
Setup hosting for a Static Website
Create a production build:
npm run build
Copy over the build:
rsync -avzr --delete build/ <server>:/var/www/<hostname>/html
CI/CD¶
Use GitHub Actions
Add a lint npm run command to package.json:
"scripts": {
"lint": "eslint src/**/*.{js,json}",
},
Generate a password-less SSH key and copy over the public key to .ssh/authorized_keys on the server being deployed to.
In the GitHub repo for the project add a DEPLOY_KEY secret and paste in the private key. Add a DEPLOY_DESTINATION secret
and paste in <username>@<server>:/var/www/<hostname>/html
Create a .github/workflows/deploy.yml action:
name: Build and Deploy
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: '12.x'
- name: npm install, test, and build
run: |
npm ci
npm run lint
npm test
npm run build
env:
CI: true
name: CI
- name: Deploy the build
id: deploy
uses: Pendect/action-rsyncer@v1.1.0
env:
DEPLOY_KEY: ${{secrets.DEPLOY_KEY}}
with:
flags: '-avzr --delete'
options: ''
ssh_options: ''
src: 'build/'
dest: ${{ secrets.DEPLOY_DESTINATION }}
- name: Display status from deploy
run: echo "${{ steps.deploy.outputs.status }}"
Push to the repo to trigger the action