December 22, 2017

Browser Navigation in React Components

The modern web has seen the rise of client-side frameworks, such as React, and Single-Page-Applications (SPAs) where pages are generated and updated client-side (inside the browser by JavaScript) unlike traditional web applications where pages are generated server-side (on a remote computer) for display in a browser.

Page content generated client-side is often independent of a URL unlike server-side pages where pages and URLs are tightly coupled. The browser back and forward buttons were created in an era where every web application was server-side generated and each page matched a unique URL. Unfortunately the back and forward buttons, by default, do not work as users expect in client-side SPAs, rather than transitioning back through page changes and updates, the back (and forward) button will unexpectedly navigate completely out of the SPA.

Using hash-bangs in URLs were a solution of sorts a few years ago but their usage is now frowned upon. However, modern browsers do provide an elegant solution for this client-side SPA navigation issue, that being the HTML5 History API, more specially the pushState function.

This post will describe how to use pushState in a simple React application such that browser navigation correctly transitions through client-side page changes.

Example Application

For this article we will reference a simple React application. The main component, named BookList, will primarily render a paginated list of books fetched from an API endpoint.

In-page next and previous links are provided by a separate Paginator component (not documented here), to navigate through the list of books.

A rough outline of the BookList component would be:

const BOOKS_ENDPOINT = 'http://localhost:3000/books.json';

class BookList extends Component {
  constructor(props) {
    super(props);

    this.params = {};
    this.state = {
      books: [],
      pagination: {}
    };
  }

  componentDidMount() {
    this.fetchBooks();
  }

  handlePageChange = (page) => {
    this.params.page = page;
    this.fetchBooks();
  }

  booksURL() {
    const params = queryString.stringify(this.params);

    return params.length > 0 ? `${BOOKS_ENDPOINT}?${params}` : BOOKS_ENDPOINT;
  }

  fetchBooks() {
    axios.get(this.booksURL())
      .then(response => {
        this.setState({
          books: response.data.books,
          pagination: response.data.pagination,
        });
      });
  }

  renderBooks() {
    return (
      this.state.books.map(book => <li key={book.id}>{book.title}</li>)
    );
  }

  render() {
    return (
      <div>
        <h1>Book List</h1>
        <ul>{this.renderBooks()}</ul>
        <Paginator pagination={this.state.pagination} onPageChange={this.handlePageChange} />
      </div>
    );
  }
}

export default BookList;

Note, the query parameters for the API end-point are stored in a member variable, as against component state, since we do not want to re-render when it changes. Also, Axios can be replaced with fetch if you so choose.

Pushing State into History

Access to the history API is not provided with React. You will need to install the React Router package to gain access to that API. Most non-trivial React applications will already be using React Router, if not then install it:

yarn add react-router-dom

Next you may need to wrap your component with withRouter to gain access to needed history and location properties. If your component is already a Route component then nothing needs to be done otherwise please wrap as follows:

import { withRouter } from 'react-router-dom';
...
export default withRouter(BookList);

Now push the required state, this.params in this case, into history:

  handlePageChange = (page) => {
    this.params.page = page;
    this.props.history.push('/', this.params);
    this.fetchBooks();
  }

The URL page parameter, stored in this.params, is the only state we need to push into history. This SPA will only change when a user clicks through pages, hence the page parameter is the only state we need to record in browser history.

Note, in the push call the first parameter ('/' above) should be the actual URL of your component. In this simple application BookList will be mounted at the root URL.

Preventing render when location changes

A minor issue above is that the BooksList component will now be rendered twice when handlePageChange is invoked, once when this.props.history.push is called and once in the fetchBooks function when setState is called. In our example pushing state into history will always be followed fetchBooks. Lets use the React lifecycle method componentShouldUpdate to ignore locations changes:

  shouldComponentUpdate(nextProps) {
    return _.isEqual(this.props.location, nextProps.location);
  }

In the above shouldComponentUpdate call we will not re-render the component when the location changes. In our case that will safe, but that may not be the case with your components. Treat shouldComponentUpdate with extreme caution.

Note, we need to use Lodash isEqual to correctly test object equivalence since JavaScript === does not work as one would expect.

Browser navigation event handler

The pressing of the back or forward button in a browser is an event. Our component needs a handler for this event to correctly update the current page to a previous state.

The event of interest is window.onpopstate:

  componentDidMount() {
    window.onpopstate = this.handlePopState;
    this.applyParams();
  }

  componentWillUnmount() {
    window.onpopstate = null;
  }

  handlePopState = (event) => {
    event.preventDefault();
    this.applyParams();
  }

  applyParams() {
    this.params = this.props.location.state || {};
    this.fetchBooks();
  }

Note, it is important to unset the onpopstate when this component is being unmounted otherwise the event handler will persist beyond the lifetime of the component.

Updated example

Adding all the pieces together results in the following enhanced BookList component:

const BOOKS_ENDPOINT = 'http://localhost:3000/books.json';

class BookList extends Component {
  constructor(props) {
    super(props);

    this.params = {};
    this.state = {
      books: [],
      pagination: {}
    };
  }

  componentDidMount() {
    window.onpopstate = this.handlePopState;
    this.applyParams();
  }

  componentWillUnmount() {
    window.onpopstate = null;
  }

  shouldComponentUpdate(nextProps) {
    return _.isEqual(this.props.location, nextProps.location);
  }

  handlePopState = (event) => {
    event.preventDefault();
    this.applyParams();
  }

  applyParams() {
    this.params = this.props.location.state || {};
    this.fetchBooks();
  }

  handlePageChange = (page) => {
    this.params.page = page;
    this.props.history.push('/', this.params);
    this.fetchBooks();
  }

  booksURL() {
    const params = queryString.stringify(this.params);

    return params.length > 0 ? `${BOOKS_ENDPOINT}?${params}` : BOOKS_ENDPOINT;
  }

  fetchBooks() {
    axios.get(this.booksURL())
      .then(response => {
        this.setState({
          books: response.data.books,
          pagination: response.data.pagination,
        });
      });
  }

  renderBooks() {
    return (
      this.state.books.map(book => <li key={book.id}>{book.title}</li>)
    );
  }

  render() {
    return (
      <div>
        <h1>Book List</h1>
        <ul>{this.renderBooks()}</ul>
        <Paginator pagination={this.state.pagination} onPageChange={this.handlePageChange} />
      </div>
    );
  }
}

export default withRouter(BookList);

A user will now be able to browse through the application pages and then navigate backward and forward through these pages without issue.

Caveat

I am a novice when it comes to JavaScript and React, hence the solution described above may not be optimal. I do welcome feedback and suggestions on the topic.