Building login functionality with React

Disclaimer: the information below should be used as an illustration only. For secure login functionality, you are best served by using a third-party authentication service, like Okta.

Saving user data in order to customize user experience is a common feature in web apps. Implementing this feature requires the ability to:

  • Allow users to input a username and password combination
  • Check that combination against the combinations associated with user data in a database
  • If the combination is found, the user data is stored in a state and used to customize user experience
  • If the combination was not found, the user would be alerted that the combination was incorrect and the user would not be able view user data

I’m going walk through two different ways to build out this feature using React. The first method only makes use of state while the second uses the browser’s session storage to hold a login token.

Initial components

First, let’s build the login component:

import React from "react";

function Login() {
    
    return(
        <form>
            <div>
                <label htmlFor="username">Username</label>
                <input type="text" name="username" />
            </div>
            <div>
                <label htmlFor="password">Password</label>
                <input type="password" name="password" />
            </div>
            <div>
                <input type="submit" value="Login"/>
            </div>
        </form>
    );
}

export default Login;

Note the use of htmlFor in place of for: the use of for is reserved in Javascript, so React uses htmlFor in its place.

Next, we’ll add in an event listener to handle form submission:

import React from "react";

function Login({ login }) {

    function handleSubmit(e) {
        e.preventDefault();
        const username = e.target.username.value;
        const password = e.target.password.value;
        return login(username, password);
    };
    
    return(
        <form onSubmit={handleSubmit}>
            <div>
                <label htmlFor="username">Username</label>
                <input type="text" name="username" />
            </div>
            <div>
                <label htmlFor="password">Password</label>
                <input type="password" name="password" />
            </div>
            <div>
                <input type="submit" value="Login"/>
            </div>
        </form>
    );
}

export default Login;

handleSubmit returns login, a function that ‘s been passed into the component as a prop, with the input username and password as arguments.

Next we need to build the login function itself. For the sake of simplicity, I’ll let the App component be the immediate parent of the Login component:

import React, { useState, useEffect } from "react";
import Login from "/Login.js"

function App() {

    const [userData, setUserData] = useState();

    useEffect(() => {
        return fetch("http://localhost:3000/users")
        .then((response) => response.json())
        .then((data) => setUserData(data);         
    }, []);

    function login(username, password) {
        const userMatch = userData.find((user) => user.username === username && user.password === password);
        userMatch ? console.log("Login successful") : console.log("Incorrect combination");
    };

    return (
        <div>
            <Login login={login} />
        </div>
    );
};

export default App;

There are a few things to note here.

First, this assumes a particular structure to userData, one that holds the username and password as top-level properties of the user object. You might need to make adjustments depending on the structure of the user objects you’re working with.

Second, the userData is fetched inside of a useEffect hook. This is to prevent the data from being fetched every time App renders; we really only need the data pulled when the app first loads. Furthermore, since App will render every time setUserData is called, this would cause the fetch to be called over and over again ad infinitum...not desirable. You can read about useEffect HERE.

Third, the specific implementation of the different login methods will replace the concluding ternary operation in login.

State method

The first method we’ll look at is the simpler of the two. In this method, when there is a match for the username/password combination, we store the matching user object in its own state:

import React, { useState, useEffect } from "react";
import Login from "/Login.js"

function App() {

    const [userData, setUserData] = useState();
    const [currentUser, setCurrentUser] = useState():

    useEffect(() => {
        return fetch("http://localhost:3000/users")
        .then((response) => response.json())
        .then((data) => setUserData(data);         
    }, []);

    function login(username, password) {
        const userMatch = userData.find((user) => user.username === username && user.password === password);
        userMatch ? setCurrentUser(userMatch) : console.log("Incorrect combination");
    };

    return (
        <div>
            <Login login={login} />
        </div>
    );
};

export default App;

Any time the app needs to be tailored to the user, the relevant data can be pulled from currentUser. This method also makes for an extremely simple logout function:

function logout() {
    return setCurrentUser();
};

Since calling setCurrentUser will cause App to re-render, this will clear any user data that is on display.

The problem of client-side routing

If you’re implementing a login feature in your app then there are likely components in your app you don’t want users to see unless they’re logged in. In a simple app, this is not an issue: just don’t load components when currentUser is undefined.

But if you’re using client-side routing, your users could access components using the relevant path. This might result in them stumbling into components that will appear broken when they aren’t logged in. To prevent this, you can re-route users to the login component:

import React from "react";
import { Redirect } from "react-router-dom";
import UserCard from "/UserCard.js";

function PersonalInfo({ currentUser }) {
    
    if (currentUser) {
        return (
            <div>
                <UserCard currentUser={currentUser} />
            </div>
        );
    } else {
        return <Redirect to="/login" />;
    }
};

export default PersonalInfo;

This re-route uses the Redirect hook to accomplish the desired task. Note that for this to work, you’ll need to have created a route to the Login component. See HERE for more about React routing.

The problem with refreshing

While the state method is nice and simple, it does cause a problem when users decide to refresh the app for any reason. Modern apps allow users to remain logged-in through a refresh, but the state method won’t do that.

When the app is refreshed, currentUser is reset to undefined, immediately logging the user out. This is annoying to the user, so an alternate method is desirable.

Session storage method

One way to allow for a login that persists through a refresh is to utilize the browser’s system storage. Browsers have a simple web storage api built in to allow apps to store data locally to improve user experience: just what we’re looking for!

For this method, we’re going to create a unique token, store that token as a property in the user object in the database as well as storing it in session storage, and then checking the session storage token against the user tokens whenever the app loads.

Note that we’ll be creating a unique token rather than using the user’s username/password combination. Session storage can be accessed using the browser’s dev tools, so storing a password there would not be secure.

First, let’s create our unique token:

import React, { useState, useEffect } from "react";
import Login from "/Login.js"

function App() {

    const [userData, setUserData] = useState();
    const [currentUser, setCurrentUser] = useState();

    useEffect(() => {
        return fetch("http://localhost:3000/users")
        .then((response) => response.json())
        .then((data) => setUserData(data);         
    }, []);

    function login(username, password) {
        const userMatch = userData.find((user) => user.username === username && user.password === password);
        If (userMatch) {
            setCurrentUser(userMatch);
            return createToken(userMatch);
        } else {
            return console.log("Incorrect combination");
        };

    function createToken(user) {
        const tokenArr = userData.map((user) => user.token);
        let token = Math.random();
        while(tokenArr.includes(token)) {
            token = Math.random();
        }
        return postToken(token, user.id);
    };

    function postToken(token, userId) {
        return fetch(`http://localhost:3000/users/${userId}`, {
                method: 'PATCH',
                headers: {
                  'Content-Type': 'application/json'
                },
                body: JSON.stringify({token: token})
        })
        .then(() => storeToken(token, userId);
    };
    
    function storeToken(tokenNum, userId) {
        const tokenObj = {user: userId, token: tokenNum}
        return sessionStorage.setItem('token', JSON.stringify(userObj.token));
    };

    return (
        <div>
            <Login login={login} />
        </div>
    );
};

export default App;

We build createToken to make the actual token. Notice that we check the random number against the userData to make sure no one else is currently using that token. This isn’t a perfect solution since it is conceivable that someone else has claimed that token since the last time the app fetched the userData. I’ll leave it as an exercise for the reader to design a solution to that problem.

Notice also that we are still going to use the currentUser state. We will continue to use that to identify which user is logged in. We will ultimately use the token to set currentUser on refresh.

Next, we post the token to the user’s information in the database. We’ll be using this to validate the token in session storage.

Third, we store the token in session storage as an object with user and token properties.

At this point, we have the token stored where we want it, in places that won’t be affected by refreshing the app. Next we need to build a check to run each time the app loads to validate the token (when there is one).

import React, { useState, useEffect } from "react";
import Login from "/Login.js"

function App() {

    const [userData, setUserData] = useState();
    const [currentUser, setCurrentUser] = useState();

    useEffect(() => {
        return fetch("http://localhost:3000/users")
        .then((response) => response.json())
        .then((data) => {
            setUserData(data);
            validateToken(data);
        };         
    }, []);

    function validateToken(data) {
        const tokenString = sessionStorage.getItem('token');
        const userToken = JSON.parse(tokenString);
        if (userToken) {
            const returningUser = data.find((user) => user.id === userToken.user && user.token === userToken.token);
            returningUser ? setCurrentUser(returningUser) :
setCurrentUser();
        } else {
            return setCurrentUser();
        }
    };

First, we call our validation function as part of the initial data loading process to take advantage of the data being returned from the fetch. This allows us to avoid some problems that arise if userData doesn’t loaded before the validation check runs.

Next, we build the validation function itself. In this function, we grab the token from session storage and assign it to tokenString. Note that this tokenString is a json string and needs to be parsed to ensure that we can use it effectively. The resulting Javascript object is attached to the userToken variable.

If there was a token in session storage, we then check it against the token stored in the data for the user identified by the token. If the tokens match, we set currentUser to returningUser. If not, or if there was no token in session storage, we leave the currentUser undefined.

Some issues

The session storage method works well except it introduces another asynchronous element into the initial app load: sessionStorage.getItem('token').

This can cause a problem when a component that redirects when currentUser is undefined refreshes, redirecting a user who is, in fact, logged in.

To solve this issue, we’ll need a state that records whether the token has been checked.

import React, { useState, useEffect } from "react";
import Login from "/Login.js"

function App() {

    const [userData, setUserData] = useState();
    const [currentUser, setCurrentUser] = useState();
    const [tokenIsChecked, setTokenIsChecked] = useState();

    useEffect(() => {
        return fetch("http://localhost:3000/users")
        .then((response) => response.json())
        .then((data) => {
            setUserData(data);
            validateToken(data);
        };         
    }, []);

    function validateToken(data) {
        const tokenString = sessionStorage.getItem('token');
        const userToken = JSON.parse(tokenString);
        setTokenIsChecked(true);
        if (userToken) {
            const returningUser = data.find((user) => user.id === userToken.user && user.token === userToken.token);
            if (returningUser) {
                return setCurrentUser(returningUser);
            } else {
                return setCurrentUser();
        } else {
            return setCurrentUser();
        }
    };

The state is updated when the token is validated. Finally, we need to tweak our redirect:

import React from "react";
import { Redirect } from "react-router-dom";
import UserCard from "/UserCard.js";

function PersonalInfo({ currentUser, tokenIsChecked }) {
    
    if (tokenIsChecked) {
        if (currentUser) {
            return (
                <div>
                    <UserCard currentUser={currentUser} />
                </div>
            );
        } else {
            return <Redirect to="/login" />;
        }
    } else {
        return (
            <div>
                <h1>Loading...</h1>
            </div>
        );
    }
};

export default PersonalInfo;

So long as tokenIsChecked is falsey, the component will just show “Loading…”. Once tokenIsChecked becomes true, the original component or redirect will load.

Finally, logging out using the session storage method is only slightly more complicated than it is using the state method.

function logout() {
    sessionStorage.clear();
    setTokenIsChecked(false);
    return setCurrentUser();
};

Conclusion

For many apps, using the simpler state method for your login feature will likely be sufficient. If, however, you want to create a more user-friendly experience, you might consider using session storage.

On last note: I know next to nothing about web security. It could well be that using session storage to save a login token compromises the security of your app. So, discretion is advised.

, ,

Leave a comment