8452. Building Web Application with React and ReduxReact and Redux
Build web application with React and Redux.
1. Game Store Web Application
In the posting Building Web Application with React, I introduced how to use React to create a web application to manage products. In this tutorial, we will reuse this app and learn how to enhance it with Redux.
2. React Project
2.1 Source Files
Download the source files from Game Store(React) on GitHub, open the project in Visual Studio Code.
$ git clone https://github.com/jojozhuang/game-store-react.git
$ cd game-store-react
2.2 Installing Packages
Install new packages redux
, redux-thunk
and react-redux
.
$ npm install redux -save
$ npm install redux-thunk -save
$ npm install react-redux -save
2.3 Actions
Create file ‘src/acions/actionTypes.js
’. Define the action types.
export const LOAD_PRODUCTS_SUCCESS = 'LOAD_PRODUCTS_SUCCESS';
export const CREATE_PRODUCT_SUCCESS = 'CREATE_PRODUCT_SUCCESS';
export const UPDATE_PRODUCT_SUCCESS = 'UPDATE_PRODUCT_SUCCESS';
export const DELETE_PRODUCT_SUCCESS = 'DELETE_PRODUCT_SUCCESS';
export const UPLOAD_FILE_SUCCESS = 'UPLOAD_FILE_SUCCESS';
export const FETCH_RESOURCES_FAIL = 'FETCH_RESOURCES_FAIL';
Create file ‘src/acions/fileActions.js
’.
import * as types from './actionTypes';
import fileApi from '../api/FileApi';
export function uploadFileSuccess(response) {
return {type: types.UPLOAD_FILE_SUCCESS, response};
}
export function fetchResoucesFail(error) {
return {type: types.FETCH_RESOURCES_FAIL, error};
}
export function uploadFile(file, product) {
return function (dispatch) {
return fileApi.uploadFile(file).then(response => {
dispatch(fetchResoucesFail(null)); // clear error
dispatch(uploadFileSuccess(Object.assign(response, {product: product})));
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {product: product})));
});
};
}
The following points need to be noted about the above code.
- Function
uploadFile(file, product)
callsfileApi
to upload image. - Use
uploadFileSuccess(response)
to get the response from API service and dispatch to corresponding reducer. - Use
fetchResoucesFail(error)
to handle error.
Create file ‘src/acions/productActions.js
’.
import * as types from './actionTypes';
import productApi from '../api/ProductApi';
import history from '../history.js';
export function loadProductsSuccess(products) {
return {type: types.LOAD_PRODUCTS_SUCCESS, products};
}
export function createProductSuccess(product) {
return {type: types.CREATE_PRODUCT_SUCCESS, product};
}
export function updateProductSuccess(product) {
return {type: types.UPDATE_PRODUCT_SUCCESS, product};
}
export function deleteProductSuccess(product) {
return {type: types.DELETE_PRODUCT_SUCCESS, product};
}
export function fetchResoucesFail(error) {
return {type: types.FETCH_RESOURCES_FAIL, error};
}
export function loadProducts() {
// make async call to api, handle promise, dispatch action when promise is resolved
return function(dispatch) {
return productApi.getAllProducts().then(products => {
dispatch(loadProductsSuccess(products));
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {products: []})));
});
};
}
export function createProduct(product) {
return function (dispatch) {
return productApi.createProduct(product).then(response => {
dispatch(fetchResoucesFail(null)); // clear error
dispatch(createProductSuccess(response));
history.push('/products');
return response;
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {product: product})));
});
};
}
export function updateProduct(product) {
return function (dispatch) {
return productApi.updateProduct(product).then(response => {
dispatch(fetchResoucesFail(null)); // clear error
dispatch(updateProductSuccess(response));
history.push('/products');
return(response);
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {product: product})));
});
};
}
export function deleteProduct(product, products) {
return function(dispatch) {
return productApi.deleteProduct(product).then(() => {
dispatch(deleteProductSuccess(product));
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {products: products})));
});
};
}
The following points need to be noted about the above code.
- Four actions are defined for CRUD operations on products.
- Use
fetchResoucesFail(error)
to handle error. - Use
history.push('/products');
to navigate to products list page if there is no error when creating or updating product.
2.4 Reducers
Create file ‘src/reducers/initialState.js
’. Here, we define the data model as initial state.
products
is an array, it stores all products.response
is an object, it stores the image info if file is uploaded.error
is an object, it is set to null by default. If error occurs when calling RESTful APIs, we should set error info to it and pass to reducer for further processing.
export default {
products: [],
response: {},
error: null
};
Create file ‘src/reducers/fileReducer.js
’.
import * as types from '../actions/actionTypes';
import initialState from './initialState';
export default function fileReducer(state = initialState.response, action) {
switch(action.type) {
case types.UPLOAD_FILE_SUCCESS:
return action.response;
default:
return state;
}
}
Create file ‘src/reducers/productsReducer.js
’.
import * as types from '../actions/actionTypes';
import initialState from './initialState';
export default function productsReducer(state = initialState.products, action) {
switch(action.type) {
case types.LOAD_PRODUCTS_SUCCESS:
return action.products;
case types.CREATE_PRODUCT_SUCCESS:
return [
...state.filter(product => product.id !== action.product.id),
Object.assign({}, action.product)
];
case types.UPDATE_PRODUCT_SUCCESS:
return [
...state.filter(product => product.id !== action.product.id),
Object.assign({}, action.product)
];
case types.DELETE_PRODUCT_SUCCESS: {
const newProducts = Object.assign([], state);
const indexToDelete = state.findIndex(product => {return product.id == action.product.id;});
newProducts.splice(indexToDelete, 1);
return newProducts;
}
default:
return state;
}
}
Create file ‘src/reducers/errorReducer.js
’.
import * as types from '../actions/actionTypes';
import initialState from './initialState';
export default function errorReducer(state = initialState.error, action) {
switch(action.type) {
case types.FETCH_RESOURCES_FAIL: {
return action.error;
}
default:
return state;
}
}
Create file ‘src/reducers/rootReducer.js
’. It defines a combined reducer, including the above three reducers.
import {combineReducers} from 'redux';
import products from './productReducer';
import file from './fileReducer';
import error from './errorReducer';
const rootReducer = combineReducers({
products,
file,
error
});
export default rootReducer;
2.5 Store
Create file ‘src/store/configureStore.js
’.
import {createStore, applyMiddleware} from 'redux';
import rootReducer from '../reducers/rootReducer';
import thunk from 'redux-thunk';
export default function configureStore() {
return createStore(
rootReducer,
applyMiddleware(thunk)
);
}
2.6 Redux Setup
Update ‘src/index.js
’.
import React from 'react';
import ReactDOM from 'react-dom';
import { Router } from 'react-router-dom';
import history from './history.js';
import App from './components/App';
import { Provider } from 'react-redux';
import configureStore from './store/configureStore';
import {loadProducts} from './actions/productActions';
const store = configureStore();
store.dispatch(loadProducts());
ReactDOM.render(
<Provider store={store}>
<Router history={history}>
<App />
</Router>
</Provider>,
document.getElementById('root')
);
Following changes are made to this component.
- Use
configureStore()
to get store. - Use
loadProducts()
to get all products once this app is launched. - Set
store
attribute onProvider
to setup redux on this app. - Use
Router
instead ofBrowserRouter
and sethistory
attribute.
2.7 Components
Update file ‘src/components/product/ProductList.js
’.
import React from 'react';
import PropTypes from 'prop-types';
import { Button, ButtonToolbar} from 'react-bootstrap';
import AlertSimple from '../controls/AlertSimple';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as productActions from '../../actions/productActions';
class ProductList extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: {},
products: this.props.products
};
this.deleteRow = this.deleteRow.bind(this);
this.handleError = this.handleError.bind(this);
}
componentWillReceiveProps(nextProps) {
this.setState({hasError: nextProps.hasError});
this.setState({error: nextProps.error});
this.setState({products: nextProps.products});
}
deleteRow (event, id) {
if(window.confirm('Are you sure to delete this product?')){
let oldProduct = this.state.products.find(product => product.id == id);
this.props.productActions.deleteProduct(oldProduct, this.state.products);
}
}
handleError(error) {
this.setState({ hasError: true });
this.setState({ error: error });
}
render() {
let alert = '';
if (this.state.hasError) {
alert = (<AlertSimple error={this.state.error}/>);
}
return (
<div className="container">
<h2>Products</h2>
<p>Data from Restful API</p>
{alert}
<table className="table">
<thead>
<tr>
<th>Product ID</th>
<th>Product Name</th>
<th>Price</th>
<th>Image</th>
<th>Operations</th>
</tr>
</thead>
<tbody>
{
this.state.products
.sort((a, b) => a.id < b.id)
.map(product => (
<tr key={product.id}>
<td>{product.id}</td>
<td>{product.productName}</td>
<td>{product.price}</td>
<td><img src={product.image} className="img-thumbnail" width="80" height="80"/></td>
<td>
<ButtonToolbar>
<Button bsStyle="success" href={'/productpage/' + product.id} >Edit</Button>
<Button bsStyle="danger" onClick={(e) => this.deleteRow(e, product.id)}>Delete</Button>
</ButtonToolbar>
</td>
</tr>)
)
}
</tbody>
</table>
</div>
);
}
}
ProductList.propTypes = {
history: PropTypes.object.isRequired,
hasError: PropTypes.bool.isRequired,
error: PropTypes.object,
products: PropTypes.array.isRequired,
productActions: PropTypes.object.isRequired
};
function mapStateToProps(state, ownProps) {
let products = state.products;
// error occurs
let hasError = state.error !== null;
if (hasError) {
products = state.error.products; // empty list, '[]'
}
return {
hasError: hasError,
error: state.error,
products: products
};
}
function mapDispatchToProps(dispatch) {
return {
productActions: bindActionCreators(productActions, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ProductList);
Following changes are made to this component.
- Use
connect()
to connect this component to store. - Use
mapDispatchToProps(dispatch)
to receives the dispatch() method and returns callback props. - Use
mapStateToProps(state, ownProps)
to getstate
from reducer and createprops
for this component. - Use
componentWillReceiveProps(nextProps)
to convertprops
tostate
. ‘nextProps’ comes from ‘mapStateToProps’. - Call
deleteProduct()
fromthis.props.productActions
instead ofproductApi
.
Update file ‘src/components/product/ProductPage.js
’.
import React from 'react';
import PropTypes from 'prop-types';
import AlertSimple from '../controls/AlertSimple';
import ProductForm from './ProductForm';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as productActions from '../../actions/productActions';
class ProductPage extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: {},
product: {id: '0', productName: '', price: '', image: process.env.API_HOST+"/images/default.png"},
isnew: false
};
this.updateProductState = this.updateProductState.bind(this);
this.handleImageChange = this.handleImageChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleError = this.handleError.bind(this);
}
componentWillReceiveProps(nextProps) {
this.setState({hasError: nextProps.hasError});
this.setState({error: nextProps.error});
this.setState({product: nextProps.product});
this.setState({isnew: nextProps.isnew});
}
updateProductState(event) {
const field = event.target.name;
const product = this.state.product;
product[field] = event.target.value;
return this.setState({product: product});
}
handleImageChange(image) {
const product = this.state.product;
product['image'] = image;
return this.setState({product: this.state.product});
}
handleSave(event) {
event.preventDefault();
let product = this.state.product;
if (this.state.isnew) {
this.props.productActions.createProduct(product);
} else {
this.props.productActions.updateProduct(product);
}
}
handleError(error) {
this.setState({ hasError: true });
this.setState({ error: error });
}
render() {
let alert = '';
if (this.state.hasError) {
alert = <AlertSimple error={this.state.error}/>;
}
let pageTitle = 'Edit Product';
if (this.state.isnew) {
pageTitle = 'Create New Product';
}
return(
<div className="container">
<h2>{pageTitle}</h2>
{alert}
<ProductForm
product={this.state.product}
isnew={this.state.isnew}
onChange={this.updateProductState}
onImageChange={this.handleImageChange}
onSave={this.handleSave}
onError={this.handleError}/>
</div>
);
}
}
ProductPage.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
hasError: PropTypes.bool.isRequired,
error: PropTypes.object,
product: PropTypes.object.isRequired,
isnew: PropTypes.bool.isRequired,
productActions: PropTypes.object.isRequired
};
function getProductById(products, id) {
let product = products.find(product => product.id == id);
return Object.assign({}, product);
}
function mapStateToProps(state, ownProps) {
const pId = ownProps.match.params.id;
let isnew = pId == null;
// new product
let product = {id: '0', productName: '', price: '', image: process.env.API_HOST+"/images/default.png"};
if (pId) { //update product
// find product from list by id
product = state.products.find(product => product.id == pId);
}
// error occurs
let hasError = state.error !== null;
let error = state.error;
if (hasError) {
product = state.error.product; // preserve the state in case user made change to the product
} else if (product == null) {
hasError = true;
error = new Error("No such product: " + pId);
product = {id: '0', productName: '', price: '', image: process.env.API_HOST+"/images/default.png"};
}
if (product == null) {
hasError = false;
error = null;
product = {id: '0', productName: '', price: '', image: process.env.API_HOST+"/images/default.png"};
}
// refresh if image is uploaded, product info needs to be preserved
if (state.file.product) {
product = state.file.product;
}
return {
hasError: hasError,
error: error,
product: product,
isnew: isnew
};
}
function mapDispatchToProps(dispatch) {
return {
productActions: bindActionCreators(productActions, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ProductPage);
Following changes are made to this component.
- Use
connect()
to connect this component to store. - Use
mapDispatchToProps(dispatch)
to receives the dispatch() method and returns callback props. - Use
mapStateToProps(state, ownProps)
to getstate
from reducer and createprops
for this component. - Use
componentWillReceiveProps(nextProps)
to convertprops
tostate
. ‘nextProps’ comes from ‘mapStateToProps’. - Call
createProduct()
andupdateProduct()
fromthis.props.productActions
instead ofproductApi
.
Update file ‘src/components/product/ImageUpload.js
’.
import React from 'react';
import PropTypes from 'prop-types';
import { FormGroup, Col, ControlLabel, FormControl, Button, Image, Label} from 'react-bootstrap';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as fileActions from '../../actions/fileActions';
class ImageUpload extends React.Component {
constructor(props) {
super(props);
this.state = {
filename: "",
file: null
};
this.handleFileChange = this.handleFileChange.bind(this);
this.handleFileUpload = this.handleFileUpload.bind(this);
}
componentWillReceiveProps(nextProps) {
this.props.onImageChange(nextProps.image); // can't set parent's props in child component, it's read-only. Instead, have to call the parent's method to update the image.
}
handleFileChange(event) {
const file = event.target.files[0];
this.setState({filename: file.name});
this.setState({file: file});
}
handleFileUpload(event) {
this.props.fileActions.uploadFile(this.state.file, this.props.product);
}
render() {
return(
<div>
<Image src={this.props.image} thumbnail width="80" height="80" />
<ControlLabel className="btn btn-success" htmlFor="fileSelector">
<FormControl id="fileSelector" type="file" style="display: none" onChange={this.handleFileChange}/>Choose Image
</ControlLabel>
<Label bsStyle="info">{this.state.filename}</Label>
<Button bsStyle="primary" type="button" onClick={this.handleFileUpload}>Upload</Button>
</div>
);
}
}
ImageUpload.propTypes = {
image: PropTypes.string.isRequired,
product: PropTypes.object.isRequired,
onImageChange: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
fileActions: PropTypes.object.isRequired
};
function mapStateToProps(state, ownProps) {
let image = ownProps.image;
if (state.file.message) {
image = state.file.message;
}
return {
image: image
};
}
function mapDispatchToProps(dispatch) {
return {
fileActions: bindActionCreators(fileActions, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ImageUpload);
Following changes are made to this component.
- Use
connect()
to connect this component to store. - Use
mapDispatchToProps(dispatch)
to receives the dispatch() method and returns callback props. - Use
mapStateToProps(state, ownProps)
to getstate
from reducer and createprops
for this component. - Use
componentWillReceiveProps(nextProps)
to convertprops
tostate
. ‘nextProps’ comes from ‘mapStateToProps’. - Call
uploadFile()
fromthis.props.fileActions
instead offileApi
.
2.8 Navigation in Actions
Though we can define routes in components, we still need to navigate programmatically with javascript for some cases. To achieve this, we need to use history.
Install the history
module.
$ npm install history --save
Create file src/history.js
.
import createHistory from 'history/createBrowserHistory';
export default createHistory();
In src/index.js
, add this history to Router component.
import history from './history.js';
<Router history={history}>
// Route tags here
</Router>
In src/actions/productActions.js
, import history and use history.push(path)
method for navigation.
import history from '../history.js';
...
export function createProduct(product) {
return function (dispatch) {
return productApi.createProduct(product).then(response => {
dispatch(fetchResoucesFail(null)); // clear error
dispatch(createProductSuccess(response));
history.push('/products');
return response;
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {product: product})));
});
};
}
...
2.9 Handling Error Globally
Define additional function fetchResoucesFail(error)
in action to handle errors.
export function fetchResoucesFail(error) {
return {type: types.FETCH_RESOURCES_FAIL, error};
}
export function loadProducts() {
return function(dispatch) {
return productApi.getAllProducts().then(products => {
dispatch(loadProductsSuccess(products));
}).catch(error => {
dispatch(fetchResoucesFail(Object.assign(error, {products: []})));
});
};
}
Create additional reducer errorReducer
to receive error from actions and forward it to components.
import * as types from '../actions/actionTypes';
import initialState from './initialState';
export default function errorReducer(state = initialState.error, action) {
switch(action.type) {
case types.FETCH_RESOURCES_FAIL: {
return action.error;
}
default:
return state;
}
}
In component’s mapStateToProps()
method, check if error exists and set it to props.
function mapStateToProps(state, ownProps) {
let products = state.products;
// error occurs
let hasError = state.error !== null;
if (hasError) {
products = state.error.products; // empty list, '[]'
}
return {
hasError: hasError,
error: state.error,
products: products
};
}
Then, in componentWillReceiveProps()
method, set error to component’s state.
componentWillReceiveProps(nextProps) {
this.setState({hasError: nextProps.hasError});
this.setState({error: nextProps.error});
this.setState({products: nextProps.products});
}
Finally, display the error in AlertSimple
component.
render() {
let alert = '';
if (this.state.hasError) {
alert = (<AlertSimple error={this.state.error}/>);
}
return (
<div className="container">
<h2>Products</h2>
<p>Data from Restful API</p>
{alert}
<table className="table">
...
</table>
</div>
);
}
}
In some cases, we need to preserve the component state when displaying the error. So, we need to pass the current state to reducer. The below sample code shows we append product
state to the error object and pass them together to component.
dispatch(fetchResoucesFail(Object.assign(error, {product: product})));
2.10 Final Project Structure
4. Running and Testing
Start the RESTful service first, and start this React app, serve it in web server.
$ npm start
Open web browser, access ‘http://localhost:12090/’. Click the List button. There are three products with images. Click the ‘Create’ button, input product name and price. And click ‘Choose Image’ to select an image from local disk. Then, click ‘Upload’ button to upload it to the remote server. The image will be displayed at the left side. Click ‘Save’ button, product is saved. Click ‘Edit’ button of the new added product. Change the product name and price. Click ‘Save’ button, product(ID=4) is updated. Click ‘Delete’ button of the last product. A popup window for confirming the delete operation shows up. Click ‘OK’ button, product will be deleted.
5. Source Files
- Source files of Game Store(React+Redux) on Github
- Source files of RESTful API(ASP.NET Core) on Github
- Source files of RESTful API(Spring Boot) on Github