DEV Community

Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Conquer navigation state with React-router and Redux

A fundamental component of traditional applications and single page applications alike is Navigation — being able to move from one page to another.

Okay, and so what?

Wait up!

In this article, I’ll not only show you the nuances of navigating within your React/Redux applications, I’ll show you how to do this declaratively! You’ll also learn how to maintain state across your app’s navigation switches.

Ready?

👉 NB: In this article, I assume you have a decent understanding of how Redux works. If not, you may want to check out my article on Understanding Redux 📕.

The application we’ll be working with

In a bid to make this as pragmatic as possible, I have set up a simple application for this purpose.

The following GIF is that of EmojiLand.

Hello, EmojiLand!

EmojiLand is a simple app, but just good enough to help you digest the very important tips I’ll be sharing in this article.

Notice how the app stays on a current route, but when the button is clicked it performs some fake action and redirects to another route upon completion of the fake action.

In the real world, this fake action could be a network request to fetch some resource, or any other async action.

Just so we’re on the same page, let me share a bit on how the EmojiLand app is built.

A quick overview of how EmojiLand is built

To work along, grab the application’s repo from GitHub. If you’re feeling lazy, feel free to skip.

Clone the repo: git clone https://github.com/ohansemmanuel/nav-state-react-router.git

Move into the directory: cd nav-state-react-router

Install the dependencies: yarn install or npm install

Then run the application: yarn start or npm start

Done that?

The app is a basic react with redux setup. A very minimal set up with react-router is also included.

In containers/App.js you’ll find the 6 routes contained in this application.

Below’s the full code representation:

const App = () => (
  <Router>
    <Switch>
      <Route exact path="/" component={AngryDude} />
      <Route path="/quiet" component={KeepQuiet} />
      <Route path="/smile" component={SmileLady} />
      <Route path="/think" component={ThinkHard} />
      <Route path="/thumbs" component={ThumbsUp} />
      <Route path="/excited" component={BeExcited} />
    </Switch>
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

Each route leads to an emoji component. /quiet renders the KeepQuiet component.

And here’s what the KeepQuiet component looks like:

import React from "react";
import EmojiLand from "../components/EmojiLand";
import keepQuietImg from "../Images/keepquiet.png";
import emojiLand from "./emojiLand";
const KeepQuiet = ({ appState, handleEmojiAction }) => (
    <EmojiLand
      EmojiBg="linear-gradient(120deg, #a6c0fe 0%, #f68084 100%)"
      EmojiImg={keepQuietImg}
      EmojiBtnText="Keep Calm and Stay Quiet."
      HandleEmojiAction={handleEmojiAction}
      appState={appState}
    />
  );
export default emojiLand(KeepQuiet);
Enter fullscreen mode Exit fullscreen mode

It is simple functional component that renders an EmojiLand component. The construct of the EmojiLand component is in components/EmojiLand.js.

It is isn’t very much of a big deal, and you can have a look on GitHub.

What’s important is that it takes in some props such as a background gradient, image, and button text.

What’s more delicate is the exported component.

Please have a look at the last line of the code block above.

export default emojiLand(KeepQuiet);
Enter fullscreen mode Exit fullscreen mode

emojiLand right there is an higher order component. All it does is make sure that when you click a button in any of the emoji components, it simulates a fake action for about 1000ms. Remember that in practice this may be a network request.

The emojiLand higher order component does this by passing the appState props into the emoji components. In this example, KeepQuiet

When any of the emoji components is first rendered, appState is an empty string, "". After about 1000ms, appState is changed to DO_SOMETHING_OVER.

Where DO_SOMETHING_OVER is represented as a constant, just as shown below:

In constants/action-types:

export const DO_SOMETHING_OVER = "DO_SOMETHING_OVER";
Enter fullscreen mode Exit fullscreen mode

Now, this is how every emoji component in this app works!

Also remember that at each route, a separate EmojiLand component is rendered.

AngryDude, BeExcited, & BeQuiet

SmileLady, ThinkHard, & ThumbsUp

Redirecting like a React champ

Upon the completion of the fake process, let’s assume you wanted to redirect/move to another route within the EmojiLand application.

How do you do this?

Firstly, remember that on hitting the home route, what’s rendered is theAngryDude component.

The AngryDude component.

The more declarative approach for handling redirects is to use the Redirect component from React-router.

Let me show you how.

Since we want to redirect from the AngryDude component, first, you import the Redirect component within containers/AngryDude.js like this:

import { Redirect } from "react-router-dom";
Enter fullscreen mode Exit fullscreen mode

For redirects to work, it has to be rendered like a regular component. In our particular example, we’ll be redirecting when the appState holds the value, DO_SOMETHING_OVER i.e the fake action has been completed.

Now, here’s the code for that:

const AngryDude = ({ appState, handleEmojiAction }) => {
    return appState === DO_SOMETHING_OVER ? (
<Redirect to="/thumbs" />
    ) : (
      <EmojiLand
        EmojiBg="linear-gradient(-180deg, #611A51 0%, #10096D 100%)"
        EmojiImg={angryDudeImg}
        EmojiBtnText="I'm so pissed. Click me"
        HandleEmojiAction={this._handleEmojiAction}
        appState={this.props.appState}
 />
Enter fullscreen mode Exit fullscreen mode

Now, if appState is equal to DO_SOMETHING_OVER, the Redirect component is rendered.

<Redirect to="/thumbs" />
Enter fullscreen mode Exit fullscreen mode

Note that a required to prop is added to the Redirect component. This prop is required to know where to redirect to.

With that in place, here’s that in action:

Redirecting from “Angry” to “Thumbs up”

If we go ahead an do the same for the other route components, we can successfully redirect through all the routes!

Here’s that in action:

Redirecting through all the routes!

That was easy, right?

There’s a bit of a problem though, and I’ll address that in the next section.

Avoiding redirects from replacing the current route in history

I’ll open up a new browser and click through the app, but at some point, I’ll attempt to go back i.e by using the back browser button.

Have a look below:

Attempting to “go back” takes me back to the browser’s homepage :(

Note that when I click the back button, it doesn’t go back to the previous route but takes me back to my browser’s homepage.

Why?

This is because by default, using the Redirect component will replace the current location in the browser’s history stack.

So, even though we cycled multiple routes, the routes replaced each other in the browser’s “records”.

To the browser, we only visited one route. Thus, hitting the back button took me back to the homepage.

It’s like having an Array — but instead of pushing to the array, you replace the current value in the array.

There’s a fix though.

The Redirect component can take a push prop that deactivates this behaviour. With the push prop, each route is pushed unto the browser’s history stack and NOT replaced.

Here’s how that looks in code:

return appState === DO_SOMETHING_OVER ? (
    <Redirect push to="/thumbs" />
  ) : (
    <EmojiLand
      EmojiBg="linear-gradient(-180deg, #611A51 0%, #10096D 100%)"
      EmojiImg={angryDudeImg}
      EmojiBtnText="I'm so pissed. Click me"
      HandleEmojiAction={handleEmojiAction}
      appState={appState}
    />
  );
Enter fullscreen mode Exit fullscreen mode

And here’s the result of that.

Now, clicking the back button works just as expected :)

Note how we can now navigate back to previously visited routes!

Maintaining navigation state

As you move from one route to another, variables in the previous route aren’t carried over to the next route. They are gone!

Yes gone, except you do some work on your end.

What’s interesting is that the Redirect component makes this quite easy.

As opposed to passing a string to prop into Redirect, you could also pass in an object.

You could also pass in an object to the Redirect component

What’s interesting is that with the object representation, you can also pass in a state object.

Within the state object you may now store any key value pairs you wish to carry over to the route being redirected to.

Adding a State object within the “to” prop.

Let’s see an example in code.

When redirecting from the AngryDude component to ThumbsUp, let’s pass in some values into the state field.

Here’s what we had before:

<Redirect push to="/thumbs" />
Enter fullscreen mode Exit fullscreen mode

That’s to be changed to this:

<Redirect
      push
to={{
        pathname: "/thumbs",
        state: {
          humanType: "Cat Person",
          age: 12,
          sex: "none"
        }
      }}
    />
Enter fullscreen mode Exit fullscreen mode

Now, I have passed in 3 different key value pairs! humanType, age, and sex.

But upon redirecting to the /thumbs route, how do I receive these values?

For route components, react-router makes available a certain location prop. Within this location prop, you may access the state object like this, location.state or this.props.location.state

NB: Route components are components rendered by the react-router’s component . They are usually in the signature,

Here’s an example of me logging the state object received in the new route, /thumbs i.e within the newly rendered Thumbs component.

const ThumbsUp = ({ appState, handleEmojiAction, location }) => {
console.log(location.state);
  return appState === DO_SOMETHING_OVER ? (
    <Redirect push to="/quiet" />
  ) : (
    <EmojiLand
      EmojiBg="linear-gradient(-225deg, #DFFFCD 0%, #90F9C4 48%, #39F3BB 100%)"
      EmojiImg={thumbsUpImg}
      EmojiBtnText="You rock. Thumbs up!"
      HandleEmojiAction={handleEmojiAction}
      appState={appState}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Note how the location prop is deconstructed and then there’s the console.log(location.state).

After been redirected, and the dev console inspected, the state object is indeed right there!

The state object recieved and logged in the new /thumbs route!

You may even go a little further and actually render some UI component based on the passed in state.

Here’s what I did:

Have a look at the text below the button. The values have been grabbed from the location state object!

By grabbing the state passed into ThumbsUp, I mapped over it and rendered the values below the button. If you care about how I did that, have a look at the source code in components/EmojiLand.js

Now we’ve made some decent progress!

Any real world value?

You may have wondered all the while, “yes this is cool, but where do I use it in the real world?”

There are many use cases, but one very common one is where you have a list of results rendered in a table.

However, each row in this table is clickable, and upon clicking a row, you want to display even more information about the clicked values.

You could use the concepts here to redirect to the new route and also pass in some values from the table row to the new route! All by utilising the redirect’s state object within the to prop!

But, there’s another solution!

In the dev world, there are usually multiple ways to solve a problem. I want this article to be as pragmatic as possible, so I’ll show you the other possible way to navigate between routes.

Assume that we wanted to be redirected to from the /thumbs route to the quiet route after performing some action. In this case, we want to do this without using the Redirect component.

How would you go about this?

Unlike the previous solution where we rendered the Redirect component, you could use the slightly more imperative method shown below:

history.push("/quiet) or this.props.history.push("/quiet")

Okay, but where does this history object come from?

Just like location in the previous example, react-router also passes down a history prop into route components.

Here’s what we had in containers/Thumbs.js :

const ThumbsUp = ({ appState, handleEmojiAction, location }) => {
  return appState === DO_SOMETHING_OVER ? (
    <Redirect push to="/quiet" />
  ) : (
    <EmojiLand
      EmojiBg="linear-gradient(-225deg, #DFFFCD 0%, #90F9C4 48%, #39F3BB 100%)"
      EmojiImg={thumbsUpImg}
      EmojiBtnText="You rock. Thumbs up!"
      HandleEmojiAction={handleEmojiAction}
      appState={appState}
      locationState={location.state}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Now, we may use the history object like this:

const ThumbsUp = ({ appState, handleEmojiAction, location, history }) => {
  if (appState === DO_SOMETHING_OVER) {
history.push("/quiet");
  }
  return (
    <EmojiLand
      EmojiBg="linear-gradient(-225deg, #DFFFCD 0%, #90F9C4 48%, #39F3BB 100%)"
      EmojiImg={thumbsUpImg}
      EmojiBtnText="You rock. Thumbs up!"
      HandleEmojiAction={handleEmojiAction}
      appState={appState}
      locationState={location.state}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

And now, the results are just the same:

using the history prop works just as fine!

Just as expected, we still had the redirection possible!

It is important to note that you can also pass in some state values like this:

history.push("/quiet", {
 hello: "state value"
})
Enter fullscreen mode Exit fullscreen mode

Simply pass in a second object parameter into the history.push function.

We’ve got all this out of the box

Do you realise that we haven’t had to do any “extra” work on the Redux side of things?

All we had to do was learn the APIs react-router makes available. This is good, and it explains the fact that react-router and redux work just fine out of the box.

This app uses redux, but that’s not a problem.

Got that?

Something is (or perhaps, may be) wrong with our approach

Actually, nothing is wrong with the approaches we’ve discussed so far. They work just fine!

However, there are a few caveats, and depending on how you love to work, or the project you’re working on, you may not find them bearable.

Mind you, I have worked with the previous patterns on large scale project and they work just fine.

However, a lot of Redux purists would prefer to be able to navigate routes by dispatching actions. Since that’s the primary way of provoking a state change.

Also, many also prefer to synchronise the routing data with the Redux store i.e to have the route data saved within the Redux store.

Lastly, they also crave being able to enjoy support for time travel debugging in their Redux devtools as you navigate various routes.

Now, all of this isn’t possible without some sort of deeper integration between react-router and redux.

So, how can this be done?

Considering a deeper integration between React-Router and Redux

In the past, react-router offered the library, react-router-redux for this purpose. However, at the time of writing, the project has been deprecated and is no longer maintained.

Project deprecated, as seen on the react-router-redux github repo.

I guess it can be still be used, but you may have some fears using a deprecated library in production.

There’s still good news as the react-router-redux maintainers advice you use the library, connected-react-router

It does have a bit of setup to use, but it isn’t a lot if you need the benefits it gives.

Let’s see how that works, and what we may learn from integrating that into our project, Emojiland.

Integrating Connected-React-Router into EmojiLand

The first set of things to do are with the Redux store.

1. Create a history object

Technically, there’s a DOM history object for manipulating the browser’s history session.

Let’s programmatically create one ourselves.

To do this, import createBrowserHistory from history

In store/index.js:

...
import { createBrowserHistory } from 'history' 
...
Enter fullscreen mode Exit fullscreen mode

history is a dependency of the react-router-dom package, and It’s likely already installed when you use react-router in your app.

After importing createBrowserHistory, create the history object like this:

..
const history = createBrowserHistory()
Enter fullscreen mode Exit fullscreen mode

Still in the store/index.js file.

Before now, the store was created very simply, like this:

const store = createStore(reducer);
Enter fullscreen mode Exit fullscreen mode

Where the reducer refers to a reducer function in reducers/index.js, but that won’t be the case very soon.

2. Wrap the root reducer

Import the following helper function from the connected-react-router library

import { connectRouter } from 'connected-react-router'
Enter fullscreen mode Exit fullscreen mode

The root reducer must now be wrapped as shown below:

const store = createStore(connectRouter(history)(reducer));
Enter fullscreen mode Exit fullscreen mode

Now the reducer will keep track of the router state. Don’t worry, you’ll see what that means in a bit.

In order to see the effect of we’ve done has so far, in index.js I have exported the redux store globally, like this:

window.store = store;
Enter fullscreen mode Exit fullscreen mode

Now, within the browser console, you can check what’s in the redux state object with store.getState()

Here’s that in action:

Looking in the dev console for the router field now in the Redux state

As you can see, there’s now a router field in the redux store! This router field will always hold information about the current route via a location object e.g pathname, state etc.

We aren’t done yet.

In order to dispatch route actions, we need to apply a custom middleware from the connected-react-router library.

That’s explained next

3. Including a custom middleware

To include the custom middleware for handling dispatched actions, import the needed routerMiddleware middleware from the library:

...
import { connectRouter, routerMiddleware } from 'connected-react-router'
Enter fullscreen mode Exit fullscreen mode

Then use the applyMiddleware function from redux:

... 
import { createStore, applyMiddleware } from "redux";
... 
Enter fullscreen mode Exit fullscreen mode
const store = createStore(
  connectRouter(history)(reducer),
applyMiddleware(routerMiddleware(history))
);
Enter fullscreen mode Exit fullscreen mode

Now, we’re almost done. Just one more step.

4. Use the Connected Router!

Remember that react-redux gives us a Route component. However, we need to wrap these Route components in a ConnectedRouter component from the connected-react-router library.

Here’s how:

First, in index.js you import the ConnectedRouter component.

import { ConnectedRouter } from 'connected-react-router' 
...
Enter fullscreen mode Exit fullscreen mode

Here’s the render function of the index.js file:

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

Remember that App renders the different routes in the app.

const App = () => (
  <Router>
    <Switch>
      <Route exact path="/" component={AngryDude} />
      <Route path="/quiet" component={KeepQuiet} />
      <Route path="/smile" component={SmileLady} />
      <Route path="/think" component={ThinkHard} />
      <Route path="/thumbs" component={ThumbsUp} />
      <Route path="/excited" component={BeExcited} />
    </Switch>
  </Router>
);
Enter fullscreen mode Exit fullscreen mode

Now, in index.js , wrap the App component with the ConnectedRouter component. The ConnectedRouter component should be placed second only to the Provider component from react-router

Here’s what I mean:

render(
  <Provider store={store}>
 <ConnectedRouter>
      <App />
</ConnectedRouter>
  </Provider>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

One more thing!

Right now, the app won’t work as expected because the ConnectedRouter requires a history prop i.e the history object we created earlier.

The app now throws this error :(

Since we need the same object in more than one place, we need it as an exported module.

A quick fix is to create a new file store/history.js

import { createBrowserHistory } from "history";
const history = createBrowserHistory();
Enter fullscreen mode Exit fullscreen mode
export default history;
Enter fullscreen mode Exit fullscreen mode

Now, this exported history object will be used in the both places where it is needed.

In index.js it is imported like this:

import history from "./store/history";
Enter fullscreen mode Exit fullscreen mode

And then passed into the ConnectedRouter component as shown below:

render(
  <Provider store={store}>
    <ConnectedRouter history={history}>
      <App />
    </ConnectedRouter>
  </Provider>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

With this, the setup is done, and the app works — without the pesky errors we saw earlier!

Keep in mind I have only set up the connected-react-router but I encourage you check out the more advanced usage of this library.

There’s more you can do with the connected-react-router library and most of those are documented in the official FAQs. Also, if you have a more robust set up with the Redux devtools, and a logger middleware, be sure to take advantage of time travel and the action logger!

Conclusion

I hope this has been as much fun as it was for me!

If you’ve got any questions, be sure to drop them in the comment section and I’ll be happy to help.

Go build something awesome, and I’ll catch you later!


Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.


The post Conquer navigation state with React-router and Redux appeared first on LogRocket Blog.

Top comments (0)