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)callsfileApito 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.
- productsis an array, it stores all products.
- responseis an object, it stores the image info if file is uploaded.
- erroris 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 storeattribute onProviderto setup redux on this app.
- Use Routerinstead ofBrowserRouterand sethistoryattribute.
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 getstatefrom reducer and createpropsfor this component.
- Use componentWillReceiveProps(nextProps)to convertpropstostate. ‘nextProps’ comes from ‘mapStateToProps’.
- Call deleteProduct()fromthis.props.productActionsinstead 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 getstatefrom reducer and createpropsfor this component.
- Use componentWillReceiveProps(nextProps)to convertpropstostate. ‘nextProps’ comes from ‘mapStateToProps’.
- Call createProduct()andupdateProduct()fromthis.props.productActionsinstead 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 getstatefrom reducer and createpropsfor this component.
- Use componentWillReceiveProps(nextProps)to convertpropstostate. ‘nextProps’ comes from ‘mapStateToProps’.
- Call uploadFile()fromthis.props.fileActionsinstead 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 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 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 ‘Save’ button, product is saved.
 Click ‘Edit’ button of the new added product. Change the product name and price.
Click ‘Edit’ button of the new added product. Change the product name and price.
 Click ‘Save’ button, product(ID=4) is updated.
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 ‘Delete’ button of the last product. A popup window for confirming the delete operation shows up.
 Click ‘OK’ button, product will be deleted.
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