Living without React Router

How do you handle the scenario when a user taps the “back button” in your ReactJS app? In a plain vanilla ReactJS app you’ll end up with the user navigating off your app since it’s a SPA. Most devs would tell you to use React-Router. What if you can’t, or – how does React-Router work?

React-Router provides many more benefits other than solving this particular problem, so you should always attempt to use that if possible. However, in a nutshell, react-router employs the browser’s History API to intercept whenever a user presses the back button, and take appropriate action.

In this article we’ll replicate that behavior ourselves

First, the basic app. One of my current favorite approaches is to use react hooks to change the content of a component depending on an action the user took – specifically the useReducer hook. A simple example would be:

const currentViewReducer = (state, action) => {
   //assuming "action" to be an object similar to:
   //{ view: 'example1'}

   switch(action.view){
       case 'example1':
           return <div>Example 1</div>;
       case 'example2':
           return <div>Example 2</div>;
       default:
           return <div>Default</div>;
   }

}

const App = () => {

   const [currentView, currentViewDispatcher] = useReducer(currentViewReducer,<div>Hello World!</div>)

    return <React.Fragment>
              {currentView}
           <React.Fragment>
}

A couple of pointers about the snippet above:

  • Note that the reducer function is defined outside of the scope of the App function. This is done on purpose to avoid the function being called multiple times. Recall that the App function gets called whenever a re-render happens – which in the case above is whenever the “currentView” state changes. So you’d end up in a situation where the reducer gets called multiple times because:
    • some component calls the reducer.
    • the reducer returns the new “currentView”
    • the App function needs to re-render since currentView has changed
    • in the process of the re-render the reducer gets re-run again – getting called twice overall
  • The above is also the reason why useReducer is a better option here than the simpler useState. The latter risks you falling afoul of unnecessary re-renders or missing necessary re-renders due to incorrect dependencies for other hooks like useEffect.

With that defined, changing the view of the App becomes a simple matter of calling

 currentViewDispatcher( {view: '<SOMETHING_OR_OTHER>'} )

Keeping a history

At this stage, we’re still not doing much about the back button. Whenever we change the view, we need to push a new item in the browser’s history. This is where the history API comes in. Specifically, we add the following to the reducer:

   window.history.pushState(action, 'demo', 'demo')

The pushState API takes three arguments:

  • state
  • title
  • url

What we’re interested in is the “state”. In my example above I’ve set it to the reducer action, which contains the name of the view the user is navigating to. Now, when the user presses the back button, a “popstate” event is going to be fired. We can listen to this event in our react app as follows:

  window.onpopstate= (historyEvent) => {
    currentViewDispatcher({
      backtrack: true,
      ...historyEvent.state
    });
  }

Note the “onpopstate” callback takes a single argument – the history event that we originally pushed onto the “history stack”. The history event contains the “state” object which we can use to determine where the user was so we can change the App view back to that.

In my example above, I change the view by simply calling currentViewDispatcher again, but this time we include a boolean object variable “backtrack” set to true. This so that we can check for the presence of this variable in the reducer to avoid re-pushing another history event. The whole reducer looks something like this:

const currentViewReducer = (state, action) => {
   //assuming "action" to be an object similar to:
   //{ view: 'example1'}

   if (!action.backtrack){
       window.history.pushState(action, 'demo', 'demo')  
   }   

   switch(action.view){
       case 'example1':
           return <div>Example 1</div>;
       case 'example2':
           return <div>Example 2</div>;
       default:
           return <div>Default</div>;
   }

}

const App = () => {

   const [currentView, currentViewDispatcher] = useReducer(currentViewReducer,<div>Hello World!</div>)

    return <React.Fragment>
              {currentView}
           <React.Fragment>
}

With the above in place, we can change the view whenever a user presses some component that calls “currentViewReducer” with the appropriate action, and the back button will also call currentViewReducer (due to the window.onpopstate listener), with the previous action, however with action.backtrack set to true so that we don’t re-push this and go into an infinite history loop…

… react-router handles all this and more under the hood.

Advertisements