DEV Community

mychal
mychal

Posted on

Protected Routes with React Function Components

React Hooks + Router

Protected routes allow us to ensure only logged in users can access certain parts of our site that may contain private user information. In this post, we'll look at one way of implementing protected routes in React using function components along with react-router. We'll do this first with the useState hook, but in a subsequent post we'll also see how this can be achieved with React's Context API, which is a built-in solution for giving nested child components access to our application's state without the need to pass props all the down our component tree, a practice often referred to as prop drilling.

Getting Started

Let's start by creating a new React project. I'll be using the Create-React-App utility to automatically generate a baseline React app without needing to write any of the boilerplate configuration.

I'm using yarn but you can use npx if you prefer. Let's run the following command in our terminal:

yarn create react-app protected-routes

cd into the newly created protected-routes folder and run yarn start (or npm start) to fire up the dev server. It should open up your browser and display the React logo.

Create React App default page

React Router

So now that we have a basic React app, let's create a new directory called components inside the src directory. We'll create 2 components inside this directory: Landing.js and Dashboard.js

protected-routes/src/components/Landing.js
import React from 'react';
import { Link } from 'react-router-dom';

const Landing = () => {
  return (
    <div>
      <h1>Landing</h1>
      <p><Link to='/dashboard'>View Dashboard</Link></p>
      <button>Log In</button>
    </div>
  )
};

export default Landing;

Note, we're using the <Link> component from react-router instead of an anchor tag so that our app won't reload the page when the user clicks the link.

protected-routes/src/components/Dashboard.js
import React from 'react';

const Dashboard = () => {
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Secret Page</p>
      <button>Log Out</button>
    </div>
  )
};

export default Dashboard;

We want to render each of these components depending on the route we're on. To do so, let's install react-router-dom.

yarn add react-router-dom

Open up the App.js file under the src directory and import in the following components from our newly installed package right after the CSS import. Note, I'm giving BrowserRouter the alias Router for brevity. Let's also import the 2 components we created.

protected-routes/src/App.js
import { BrowserRouter as Router, Route } from 'react-router-dom';
import Landing from './components/Landing';
import Dashboard from './components/Dashboard';

We can delete all the stock HTML inside the return statement besides the top level <div> and replace it with our own. We'll use the Route component we just imported to specify which component corresponds to which route. Lastly, we'll need to wrap the <Route> components with our <Router> (aka BrowserRouter) component to tie it all together. Our App.js file should look like this:

import React from 'react';
import logo from './logo.svg';
import './App.css';

import { BrowserRouter as Router, Route } from 'react-router-dom';
import Landing from './components/Landing';
import Dashboard from './components/Dashboard';

function App() {
  return (
    <div className="App">
      <Router>
        <Route exact path='/' component={Landing} />
        <Route exact path='/dashboard' component={Dashboard} />
      </Router>
    </div>
  );
}

export default App;

Now, when we visit localhost:3000 in the browser, we should see a rather plain page with the headline "Landing", a link to /dashboard, and a button for "Log In" (which doesn't do anything yet). Click the link and we'll see that the page now renders the Dashboard component, since our route has now changed to localhost:3000/dashboard

Click on link to /dashboard

With react-router, there's no need to write additional conditional-rendering logic or utilize state to keep track of which component we should be displaying. Pretty cool, right? 😎. But we still have an issue: our secret Dashboard page is accessible to anyone and everyone. How we can allow only the people who are authorized to view the page navigate to it? First, we'll need to keep track of whether or not our user is logged in our not. Let's see how we can do that using the useState hook.

useState Hook

Prior to the introduction of hooks in version 16.8, the only way to have stateful components in React was through classes. As it's name implies, the useState hook allows us to use state inside a function component. Let's implement useState to keep track of our logged in status.

In App.js, import useState using destructuring in the same line we import React.

protected-routes/src/App.js
import React, { useState } from 'react';

Next, inside of our App function and right before our return block, let's use array destructuring to create a user and setUser variables, which are the first and second elements that useState returns to us, respectively. We'll pass in an initial state of false, to indicate that we are not logged in when we first visit the page.

We'll also create a function called handleLogin which will invoke setUser and flip the user value to true when we click "Log In".

function App() {
  const [user, setUser] = useState(false);

  const handleLogin = e => {
    e.preventDefault();
    setUser(true);
  }

We need to pass this handleLogin function to our Landing component, but it won't work with our current set up since we're passing Landing in as a component prop to Route. We'll need to change the prop from component to render and pass it in as a function that returns our Landing component. Trust me, it sounds more confusing than it is, but in case you want to read more about it, feel free to check out this article.

Our App.js should look like this:

protected-routes/src/App.js
import React, { useState } from 'react';
import './App.css';

import { BrowserRouter as Router, Route } from 'react-router-dom';
import Landing from './components/Landing';
import Dashboard from './components/Dashboard';

function App() {
  const [user, setUser] = useState(false)

  const handleLogin = e => {
    e.preventDefault();
    setUser(true);
  }

  return (
    <div className="App">
      <Router>
        <Route exact path='/' handleLogin={handleLogin} render={props => <Landing {...props} user={user.toString()} handleLogin={handleLogin} />} />
        <Route exact path='/dashboard' component={Dashboard} />
      </Router>
    </div>
  );
}

export default App;

Note, I'm passing in user as a string so we can display it in our Landing component. If you have the React Developer Tools Chrome extension, you can use that to inspect the app's state and make sure everything is working properly.

Let's add an onClick handler to our button in the Landing component using the handleLogin function we just passed down as a prop. Remember to pass in props as an argument, in order to access it inside our component.

protected-routes/src/components/Landing.js
import React from 'react';
import { Link } from 'react-router-dom';

const Landing = props => {
  return (
    <div>
      <h1>Landing</h1>
      <p><Link to='/dashboard'>View Dashboard</Link></p>
      <p>Logged in status: {props.user}</p>
      <button onClick={props.handleLogin}>Log In</button>
    </div>
  )
};

export default Landing;

We should now be able to click the log in button and see our status change to true. This is our state being toggled.

changing log in status

Great, we have our logged in status. Don't worry about wiring up the Log Out button in Dashboard.js for now, we'll do that in the next section.

Now we need a way to allow a user to visit the Dashboard component only if their logged in status is true. How can we enforce that? Enter the protected route component.

Protected Routes

We'll create a new file called ProtectedRoute.js within the components directory. At a high-level, this component will act as a wrapper over react-router's Route component, ultimately returning out the component we wish to render. In other words, we're passing in the component we want to render through an intermediary that abstracts away the need to keep track of state inside our <Dashboard> component. In our case, <ProtectedRoute> becomes a higher-order component. It will be responsible for checking if we're logged in before rendering the <Dashboard> compoent, otherwise it will redirect users to another page (which we'll create shortly).

protected-routes/src/components/ProtectedRoute.js
import React from 'react';
import { Route } from 'react-router-dom';

const ProtectedRoute = ({ component: Component, ...rest }) => {
  return (
    <Route {...rest} render={
      props => <Component {...rest} {...props} />
    } />
  )
}

export default ProtectedRoute;

ProtectedRoute takes in all the same props that we had previously passed into our Route component and returns the very same Route component using the render prop. Let's step through each line individually:

const ProtectedRoute = ({ component: Component, ...rest }) => {
  • Here we're passing in an object which contains all the props that we'll pass in when we call our <ProtectedRoute> component from App.js. We specify Component so we can reference it later in our render prop. We use rest syntax to pass in our other props without having to know or list them individually.
return (<Route {...rest} render={ props => <Component {...rest} {...props} />
  • We're simply returning react-router's <Route> component and using its render prop to render the Component we passed in as an argument. We pass in the ...rest props from before in addition to the default props that <Route> provides normally.

We'll see how we can add logic here to check whether or not we're logged in. First let's make sure we haven't broken anything.

Open up App.js, import the ProtectedRoute component, and replace Route with ProtectedRoute where we specify the /dashboard route. Your return should look like this:

protected-routes/src/App.js
  return (
    <div className="App">
      <Router>
        <Route exact path='/' handleLogin={handleLogin} render={
          props => <Landing {...props} user={user.toString()} 
          handleLogin={handleLogin} />} />
        <ProtectedRoute exact path='/dashboard' component={Dashboard} />
      </Router>
    </div>
  );

Fingers crossed, it should work exactly the same. Now let's go back and fix the log out button before we add the logic to our ProtectedRoute.

In App.js, create a handleLogout route that looks identical to the handleLogin route except that it toggles our user state to false. Then pass it down a prop to our ProtectedRoute component. Our full file now looks like this:

protected-routes/src/App.js
import React, { useState } from 'react';
import './App.css';

import { BrowserRouter as Router, Route } from 'react-router-dom';
import Landing from './components/Landing';
import Dashboard from './components/Dashboard';
import ProtectedRoute from './components/ProtectedRoute';

function App() {
  const [user, setUser] = useState(false)

  const handleLogin = e => {
    e.preventDefault();
    setUser(true);
  }

  const handleLogout = e => {
    e.preventDefault();
    setUser(false);
  }

  return (
    <div className="App">
      <Router>
        <Route exact path='/' handleLogin={handleLogin} render={
          props => <Landing {...props} user={user.toString()} handleLogin={handleLogin} />} />
        <ProtectedRoute exact path='/dashboard' handleLogout={handleLogout} component={Dashboard} />
      </Router>
    </div>
  );
}

export default App;

Open up Dashboard.js and add an onClick handler that will trigger our handleLogout function when we click the Log Out button. Remember to pass in a props argument to our Dashboard function where we previously had empty parentheses.

<button onClick={props.handleLogout}>Log Out</button>

Our application should now be able to keep track of our logged in status. You can click each button and use the Back button to see it in action:

Log out

Redirect Page

Let's create one more component that we'll redirect users to if they try to access our /dashboard route without first logging in. We'll make this component look a little more interesting than the rest of our black and white app by copying this cool 403 page from codepen by user @anjanas_dh

In the components directory, make a file called Unauthorized.js and add the following markup.

protected-routes/src/Unauthorized.js
import React from 'react';
import { Link } from 'react-router-dom';
import '../Unauthorized.scss';

const Unauthorized = () => {
  return (
    <div className='container'>
      <div class="gandalf">
        <div class="fireball"></div>
        <div class="skirt"></div>
        <div class="sleeves"></div>
        <div class="shoulders">
          <div class="hand left"></div>
          <div class="hand right"></div>
        </div>
        <div class="head">
          <div class="hair"></div>
          <div class="beard"></div>
        </div>
      </div>
      <div class="message">
        <h1>403 - You Shall Not Pass</h1>
        <p>Uh oh, Gandalf is blocking the way!<br />Maybe you have a typo in the url? Or you meant to go to a different location? Like...Hobbiton?</p>
      </div>
      <p><Link to='/'>Back to Home</Link></p>
    </div>
  )
}

export default Unauthorized;

Create a new SCSS file called Unauthorized.scss in the src directory and paste in these styles. I included the link to a pastebin rather than the code itself since the file is 270 lines long.

Since this is a Sass file, it won't work out of the box, but don't fret! We only need to install the node-sass module in order to get us on our wizard way 🧙‍♂️.

yarn add node-sass

Open up App.js and import the Unauthorized component and add it to our list of Route components.

import Unauthorized from './comoponents/Unauthorized';
/* omitting some of the other LOC to save space */
  return (
    <div className="App">
      <Router>
        <Route exact path='/' handleLogin={handleLogin} render={
          props => <Landing {...props} user={user.toString()}
            handleLogin={handleLogin} />} />
        <ProtectedRoute exact path='/dashboard' handleLogout={handleLogout} component={Dashboard} />
        <Route exact path='/unauthorized' component={Unauthorized} />
      </Router>
    </div>
  );
/* omitting some of the other LOC to save space */

If all went according to plan, you should see the following page when you navigate to '/unauthorized'

Unauthorized page

Protecting the Route

Ok, now we're in the home stretch! Let's add in the logic to ensure we're logged in before we can view the Dashboard component. First, let's pass in our user state as a prop to our ProtectedRoute in App.js.

protected-routes/src/App.js
<ProtectedRoute exact path='/dashboard' user={user} handleLogout={handleLogout} component={Dashboard} />

Jump back into ProtectedRoute.js and let's add a conditional that checks if our user status is set to true, in which case we'll render the Component, otherwise, redirect to our /unauthorized route. Therefore we'll need to import the <Redirect> component from react-router as well. Here's what the final ProtectedRoute component should look like:

import React from 'react';
import { Route, Redirect } from 'react-router-dom';

const ProtectedRoute = ({ component: Component, user, ...rest }) => {
  return (
    <Route {...rest} render={
      props => {
        if (user) {
          return <Component {...rest} {...props} />
        } else {
          return <Redirect to={
            {
              pathname: '/unauthorized',
              state: {
                from: props.location
              }
            }
          } />
        }
      }
    } />
  )
}

export default ProtectedRoute;

Note, we're now specifying user as one of the props we're passing into our ProtectedRoute component since we're referencing it a little later on in our if statement that checks if we've "logged in" or not.

  • If user evaluates to true, then we'll render our component as normal
  • However, if it's set to false, we'll use the <Redirect> component from react-router to redirect the user to '/unauthorized'.

Alright, now the moment of truth. Let's try accessing the "View Dashboard" link without first "logging in". We should be greeted by Gandalf the Grey.

Gandalf you shall not pass

Now let's click the log in button to simulate authentication. Our status changes to true and when we click on the "View Dashboard" link, our app should now render the Dashboard component. If we click "Log Out", we'll be immediately booted to the Unauthorized page.

Demo

Summary

We got a glimpse of how we can protect private pages with react-router and our <ProtectedRoute> higher-order component. We utilized the useState hook to give our function components access to state, which we passed down as props to child components. As mentioned at the onset, we'll take a look in a future post at how we can use the Context API to avoid having to prop drill.

If you made it to the end, thanks for reading. I appreciate any comments or suggestions, so feel free to leave them below. As always, happy hacking!

Discussion (24)

Collapse
juanortizoviedo profile image
juanortizoviedo

Not work if i refresh '/dashboard'

Collapse
franciscobenedict profile image
Francisco Benedict (DfMS) • Edited on

@juanortizoviedo - I had the exact same problem so once the web browser is refreshed whilst the URL is /dashboard, the page is automatically redirected to /unauthorized which makes sense as this is specified in ProtectedRoute.

I tried refreshing the browser whilst on /unauthorized page and it worked as expected (reloaded /unauthorized as normal).

I've since created a fresh create-react-app project with firebase and authenticating a user with either email or google provider and this exact same refresh issue is happening. It's almost as if the user is authenticated quickly enough on page load so the browser redirects the specified page or back to the root (/) URL.

So if a user (authenticated or not authenticated) refreshes the browser on a non-protected page, everything is fine but they are redirected if already on a protected page.

Have you managed to figure out how to overcome this issue? Any help would be greatly appreciated @mychal

Collapse
anthonyhumphreys profile image
Anthony Humphreys

Have a read over the answer here: stackoverflow.com/questions/279283...

Client side routing is hard! Basically what you're seeing is the server try to find something at /route, when your SPA loads from /index.html - /route doesn't 'really' exist - until your index.html is read and your JS kicks in. It really blows your mind until you get it. Using react router you can evade this issue by using hashrouting

Collapse
johnmarsden24 profile image
Jonny • Edited on

@franciscobenedict I had this exact same issue. Firebase will persist an authenticated users details in local storage, however to retrieve them initially is asynchronous which as you've experienced doesn't work when refreshing the page and you get taken to a login screen, etc.

As we will be wanting to know about this user state across multiple components its advised to wrap all this logic into a context provider. To start solving this we can utilise onAuthStateChanged from Firebase which is the recommend way of retrieving a user as it allows Firebase to initialise, it will provide either the user object or null in the callback. What we also need is a loading state, which is true on initial launch and when the user has been loaded or if there isn't one we can stop loading. To get this behaviour we can wrap onAuthStateChanged in a promise which resolves the user and call it on initial launch using useEffect. We can then wait for this promise to settle before we manage our final loading state. Finally we can decide what to do once we've finished loading and we either have or don't have a user.

Here's my code:

Helper function

const getCurrentUser = () =>
  new Promise((res, rej) => {
    functionsApp.auth().onAuthStateChanged((user) => res(user));
  });
Enter fullscreen mode Exit fullscreen mode

AuthContext.js

const AuthContext = createContext({
  user: null,
  loadingUser: true,
  setCurrentUser: () => {},
  unsetCurrentUser: () => {},
});

export const AuthContextProvider = (props) => {
  const [user, setUser] = useState(null);
  const [loadingUser, setLoadingUser] = useState(true);

  useEffect(() => {
    getCurrentUser().then((user) => {
      setUser(user);
      setLoadingUser(false);
    });
  }, []);

  const setCurrentUser = (user) => setUser(user);
  const unsetCurrentUser = () => signOutUser().then(() => setUser(null));

  const contextValue = {
    user,
    loadingUser,
    setCurrentUser,
    unsetCurrentUser,
  };

  return (
    <AuthContext.Provider value={contextValue}>
      {props.children}
    </AuthContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

ProtectedRoute.js

function ProtectedRoute({ component: Component, ...restOfProps }) {
  const { user, loadingUser } = useContext(AuthContext);

  if (loadingUser) {
    return <p>Loading..</p>;
  }

  return (
    <Route
      {...restOfProps}
      render={(props) =>
        user ? <Component {...props} /> : <Redirect to="/auth" />
      }
    />
  );
}
Enter fullscreen mode Exit fullscreen mode
Collapse
hopprdev profile image
Chris Camp

The issue seems to be as simple as the demo application doesn't have a persisting state of the user's auth. Since it's just artificially creating auth for a user and storing it in memory. The state of the user being logged in can't survive a page refresh. So naturally when you login and going to dashboard via the on page links it works but when you do a page refresh the user's current logged in state is lost from memory and defaults back to its original state of false. Look at the React Developer Tools and monitor the components state regarding the user's auth. A solution to this in a real application would be to have some state management system like context or Redux to persist the state so the accurate page shows when expected.

Collapse
faizanh profile image
Faizan • Edited on

I came across this issue when working with protected routes. The reason as you've already figured out, is due to the user state initially being sent to our components (defaults as false), so when it hits our protected route, it redirects the user to 401 unauthorised. What I did to solve this issue was check the user state before returning the routes. To "load" the app before it actually hit the router. I've attached some sample code, hopefully helps someone stuck with this.

gist.github.com/FaizanH/88ede257df...

Collapse
ahmaddevsinc profile image
ahmad-devsinc

There must be a typo

Collapse
mabradbirney profile image
maBradBirney • Edited on

What a wonderful guide!
There were a couple things that caught me while following along:

  1. You misspelled "components" in "import Unauthorized from './comoponents/Unauthorized';"
  2. Also it seems like you don't need the prop "handleLogin" in the Route component for "<Route exact path='/' handleLogin={handleLogin} render={..." unless it's there for some reason I'm unaware of.

Thank you so much for this article though! It was very helpful for me!

Collapse
sevenzark profile image
Dave M • Edited on

Unfortunately when I try this approach and hit the protected route, I get a show-stopping error: 'Invariant failed: You should not use <Route> outside a <Router>'. Seems like it doesn't like me composing something around the Route component, but I don't know why you and I get different results for that.

Collapse
lydstyl profile image
lydstyl

Thank you :-)

Collapse
mychal profile image
mychal Author

No problem, thanks for reading!

Collapse
code4kongo profile image
Code4kongo

great article
i have tried to do the same thing in my application but i have a problem
the data that i passed to the ProtectedRoutes are undefined i don't know why

at first the date are present but once i click on the login button which must change the Auth state to true after an API call.
but once i receive the data they are lost in the ProtectedRoute and are set to undefined

Collapse
niyongaboeric profile image
NiyongaboEric

Thanks for sharing your knowledge. I don't understand why implementing this idea in my other project took me a long time. You made it so simple to protect private routes in ReactJS. Thanks again.

Collapse
onlyinspace profile image
OnlyInSpace • Edited on

Anyway to implement this with page refresh?

Collapse
luigi_leano profile image
Luis Uriel Leaño

Thank you , it was very helpful

Collapse
jy03187487 profile image
Zhe Bao

Very useful, thank you very much!

Collapse
franciscobenedict profile image
Francisco Benedict (DfMS)

This is really good guide. I quite enjoyed it. Very easy to follow and concise too. Well done @mychal

Collapse
ahmadanis profile image
AHMAD

great job.... thanks

Collapse
ahmaddevsinc profile image
ahmad-devsinc

This is a highly understandable guide. Kudos to you sir

Collapse
josemfcheo profile image
José Martínez

Thanks for the knowledge!

Collapse
sumit134coder profile image
sumit mehra

what if someone just add /dashboard in url? Thats the problem i have to deal with. Any solution to it?

Collapse
srisrinu_ profile image
srinivas-challa1

After so many days of struggle finally i found it

Collapse
asifmeem profile image
Asif Foysal Meem

Thank you for the detailed breakdown of Protected Routing Mychal!

Collapse
terrodar profile image
Terrodar

Thanks! I was struggling with other tutorials to understand the HOC that abstract the logic of protecting the components but now I understand thanks to you.