Published: September 16 2021

React + Recoil - User Registration and Login Example & Tutorial

Tutorial built with React 17.0.2 and Recoil 0.4.1

Other versions available:

In this tutorial we'll go through an example of how to build a simple user registration, login and user management (CRUD) application with React and Recoil.

React + Recoil Example App Overview

The example app contains the following routes:

  • /account/login - public page for logging into the React + Recoil app.
  • /account/register - public page for registering a new user account with the app.
  • / - secure home page containing a simple welcome message to the logged in user.
  • /users - secure page displaying a list of all users in the React + Recoil app, with options to add, edit or delete users.
  • /users/add - secure page for adding a new user.
  • /users/edit/[id] - secure page for editing an existing user.

Recoil Overview

Recoil is a new state management library built by the Facebook team that simplifies global state management, it requires much less code than other libraries like Redux, and is easier to learn because it works just like React. The two core concepts of Recoil are atoms and selectors, an atom defines a unit (key) in the global state object of an app, and a selector is function that returns a value that is derived (computed) from atoms and/or other selectors. For more info on Recoil see https://recoiljs.org/docs/introduction/core-concepts.

Fake Backend API

The React + Recoil tutorial app runs with a fake backend by default to enable it to run completely in the browser without a real backend API (backend-less), to switch to a real backend API you just have to remove or comment out the 2 lines below the comment // setup fake backend located in the main index file (/src/index.jsx). You can build your own API or hook it up with the .NET or Node.js APIs available (instructions below).

Code on GitHub

The tutorial project is available on GitHub at https://github.com/cornflourblue/react-recoil-registration-login-example.

Here it is in action:(See on StackBlitz at https://stackblitz.com/edit/react-recoil-registration-login-example)


Run the React + Recoil Tutorial Example Locally

  1. Install Node.js and npm from https://nodejs.org.
  2. Download or clone the project source code from https://github.com/cornflourblue/react-recoil-registration-login-example
  3. Install all required npm packages by running npm install from the command line in the project root folder (where the package.json is located).
  4. Start the application by running npm start from the command line in the project root folder, this will launch a browser displaying the application.

For more info on setting up a React development environment see React - Setup Development Environment.


Run the React + Recoil App with a .NET API

For full details about the example .NET API see the tutorial .NET 5.0 - Simple API for Authentication, Registration and User Management. But to get up and running quickly just follow the below steps.

  1. Install the .NET SDK from https://dotnet.microsoft.com/download.
  2. Download or clone the project source code from https://github.com/cornflourblue/dotnet-5-registration-login-api
  3. Start the api by running dotnet run from the command line in the project root folder (where the WebApi.csproj file is located), you should see the message Now listening on: http://localhost:4000.
  4. Back in the React + Recoil example app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file, then start the React + Recoil app and it should now be hooked up with the .NET API.


Run the React + Recoil App with a Node.js + MySQL API

For full details about the example Node.js + MySQL API see the tutorial NodeJS + MySQL - Simple API for Authentication, Registration and User Management. But to get up and running quickly just follow the below steps.

  1. Install MySQL Community Server from https://dev.mysql.com/downloads/mysql/ and ensure it is started. Installation instructions are available at https://dev.mysql.com/doc/refman/8.0/en/installing.html.
  2. Download or clone the project source code from https://github.com/cornflourblue/node-mysql-registration-login-api
  3. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  4. Start the api by running npm start from the command line in the project root folder, you should see the message Server listening on port 4000.
  5. Back in the React + Recoil example app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file, then start the React + Recoil app and it should now be hooked up with the Node + MySQL API.


Run the React + Recoil App with a Node.js + MongoDB API

For full details about the example Node.js + MongoDB API see the tutorial NodeJS + MongoDB - Simple API for Authentication, Registration and User Management. But to get up and running quickly just follow the below steps.

  1. Install MongoDB Community Server from  https://www.mongodb.com/download-center.
  2. Run MongoDB, instructions are available on the install page for each OS at https://docs.mongodb.com/manual/administration/install-community/
  3. Download or clone the project source code from https://github.com/cornflourblue/node-mongo-registration-login-api
  4. Install all required npm packages by running npm install or npm i from the command line in the project root folder (where the package.json is located).
  5. Start the api by running npm start from the command line in the project root folder, you should see the message Server listening on port 4000.
  6. Back in the React + Recoil example app, remove or comment out the 2 lines below the comment // setup fake backend located in the /src/index.jsx file, then start the React + Recoil app and it should now be hooked up with the Node + Mongo API.


React + Recoil Tutorial Project Structure

Create React App was used to generate the base project structure with the npx create-react-app <project name> command, the tool is also used to build and serve the application. For more info about Create React App see https://create-react-app.dev/.

The project source (/src) is organised into the following folders:

  • _actions
    Action objects that contain methods for performing actions on each content type, for example the user actions object contains methods for authentication, registration and CRUD operations for users. Actions encapsulate all HTTP communication with the API and recoil state update operations.
  • _components
    React components used by pages or by other react components.
  • _helpers
    Anything that doesn't fit into the other folders and doesn't justify having its own folder.
  • _state
    Recoil atoms and selectors that define the global state available to the React application (the example app only contains atoms). For more info on Recoil see https://recoiljs.org/docs/introduction/core-concepts.
  • account
    Components used only by the /account section of the app
  • home
    Components used only by the home page
  • users
    Components used only by the /users section of the app

Each section/feature has it's own folder (account, home and users), other shared/common code such as actions, state, components, helpers etc are placed in folders prefixed with an underscore _ to easily differentiate them from features and to group them together at the top of the folder structure.

JavaScript files are organised with export statements at the top so it's easy to see all exported modules when you open a file. Export statements are followed by functions and other implementation code for each JS module.

The index.js file in each folder re-exports all of the modules from that folder so they can be imported using only the folder path instead of the full path to each module, and to enable importing multiple modules in a single import (e.g. import { Nav, Alert, PrivateRoute } from '_components';).

The baseUrl is set to "src" in the jsconfig.json file to make all import statements (without a dot '.' prefix) relative to the root folder of the project, removing the need for long relative paths like import { userActions } from '../../../_actions';.

Click any of the below links to jump down to a description of each file along with it's code:

 

Main Index Html File

Path: /public/index.html

The main index.html file is the initial page loaded by the browser that kicks everything off. Create React App (with Webpack under the hood) bundles all of the compiled javascript files together and injects them into the body of the index.html page so the scripts can be loaded and executed by the browser.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>React + Recoil - User Registration and Login Example</title>

    <!-- bootstrap css -->
    <link href="//netdna.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="app"></div>
</body>
</html>
 

Alert Actions

Path: /src/_actions/alert.actions.js

The alert actions object returned by the useAlertActions() hook function contains methods for displaying a success or error alert, and a method to clear the alert message. The success() and error() methods call the setAlert() function to update the alert atom in Recoil state which is then rendered by the alert component. The clear() function resets the Recoil alert atom to it's default value (null).

A React hook function is required because Recoil hook functions (e.g. useSetRecoilState, useResetRecoilState) can only be called within React components or hook functions.

import { useSetRecoilState, useResetRecoilState } from 'recoil';

import { alertAtom } from '_state';

export { useAlertActions };

function useAlertActions () {
    const setAlert = useSetRecoilState(alertAtom);
    const resetAlert = useResetRecoilState(alertAtom);

    return {
        success: message => setAlert({ message, type: 'alert-success' }),
        error: message => setAlert({ message, type: 'alert-danger' }),
        clear: resetAlert
    }
}
 

User Actions

Path: /src/_actions/user.actions.js

The user actions object returned by the useUserActions() hook function contains methods for user registration, authentication and CRUD operations. It handles communication between the React app and the backend api for everything related to users, and also handles Recoil state update operations for users and auth atoms. HTTP requests to the API are sent with the fetch wrapper.

The update() method updates Recoil state for the logged in user (auth) and local storage if the current user updates their own record.

The delete() method first updates the user is Recoil state with an isDeleting = true property so the UI can display a spinner on the delete button, it then makes an HTTP request to delete the user from the api, then removes the deleted user from Recoil state to remove it from the UI. If the current user deletes their own record they are automatically logged out of the app.

A React hook function is required because Recoil hook functions (e.g. useRecoilState, useSetRecoilState) can only be called within React components or hook functions.

import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';

import { history, useFetchWrapper } from '_helpers';
import { authAtom, usersAtom, userAtom } from '_state';

export { useUserActions };

function useUserActions () {
    const baseUrl = `${process.env.REACT_APP_API_URL}/users`;
    const fetchWrapper = useFetchWrapper();
    const [auth, setAuth] = useRecoilState(authAtom);
    const setUsers = useSetRecoilState(usersAtom);
    const setUser = useSetRecoilState(userAtom);

    return {
        login,
        logout,
        register,
        getAll,
        getById,
        update,
        delete: _delete,
        resetUsers: useResetRecoilState(usersAtom),
        resetUser: useResetRecoilState(userAtom)
    }

    function login({ username, password }) {
        return fetchWrapper.post(`${baseUrl}/authenticate`, { username, password })
            .then(user => {
                // store user details and jwt token in local storage to keep user logged in between page refreshes
                localStorage.setItem('user', JSON.stringify(user));
                setAuth(user);

                // get return url from location state or default to home page
                const { from } = history.location.state || { from: { pathname: '/' } };
                history.push(from);
            });
    }

    function logout() {
        // remove user from local storage, set auth state to null and redirect to login page
        localStorage.removeItem('user');
        setAuth(null);
        history.push('/account/login');
    }

    function register(user) {
        return fetchWrapper.post(`${baseUrl}/register`, user);
    }

    function getAll() {
        return fetchWrapper.get(baseUrl).then(setUsers);
    }

    function getById(id) {
        return fetchWrapper.get(`${baseUrl}/${id}`).then(setUser);
    }

    function update(id, params) {
        return fetchWrapper.put(`${baseUrl}/${id}`, params)
            .then(x => {
                // update stored user if the logged in user updated their own record
                if (id === auth?.id) {
                    // update local storage
                    const user = { ...auth, ...params };
                    localStorage.setItem('user', JSON.stringify(user));

                    // update auth user in recoil state
                    setAuth(user);
                }
                return x;
            });
    }

    // prefixed with underscored because delete is a reserved word in javascript
    function _delete(id) {
        setUsers(users => users.map(x => {
            // add isDeleting prop to user being deleted
            if (x.id === id) 
                return { ...x, isDeleting: true };

            return x;
        }));

        return fetchWrapper.delete(`${baseUrl}/${id}`)
            .then(() => {
                // remove user from list after deleting
                setUsers(users => users.filter(x => x.id !== id));

                // auto logout if the logged in user deleted their own record
                if (id === auth?.id) {
                    logout();
                }
            });
    }
}
 

Alert Component

Path: /src/_components/Alert.jsx

The alert component renders the alert from recoil state with bootstrap CSS classes, if the Recoil alert atom contains a null value nothing is rendered for the component.

The component automatically clears the alert on location change with the history.listen() method, there is also a close icon on the rendered alert that calls the alertActions.clear() method on click.

import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';

import { alertAtom } from '_state';
import { useAlertActions } from '_actions';
import { history } from '_helpers';

export { Alert };

function Alert() {
    const alert = useRecoilValue(alertAtom);
    const alertActions = useAlertActions();

    useEffect(() => {
        // clear alert on location change
        const unlisten = history.listen(alertActions.clear);

        // stop the listener when component unmounts
        return unlisten;

        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    if (!alert) return null;

    return (
        <div className="container">
            <div className="m-3">
                <div className={`alert alert-dismissable ${alert.type}`}>
                    <a className="close" onClick={alertActions.clear}>&times;</a>
                    {alert.message}
                </div>
            </div>
        </div>
    );
}
 

Nav Component

Path: /src/_components/Nav.jsx

The nav component displays the primary bar in the example. The component gets the current auth data from global Recoil state by calling useRecoilValue(authAtom) and only displays the nav if the user is logged in.

The react router NavLink component automatically adds the active class to the active nav item so it is highlighted in the UI.

import { NavLink } from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { authAtom } from '_state';
import { useUserActions } from '_actions';

export { Nav };

function Nav() {
    const auth = useRecoilValue(authAtom);
    const userActions = useUserActions();

    // only show nav when logged in
    if (!auth) return null;
    
    return (
        <nav className="navbar navbar-expand navbar-dark bg-dark">
            <div className="navbar-nav">
                <NavLink exact to="/" className="nav-item nav-link">Home</NavLink>
                <NavLink to="/users" className="nav-item nav-link">Users</NavLink>
                <a onClick={userActions.logout} className="nav-item nav-link">Logout</a>
            </div>
        </nav>
    );
}
 

Private Route

Path: /src/_components/PrivateRoute.jsx

The react private route component renders a route component if the user is logged in, if the user isn't logged in they're redirected to the /account/login page with the return url in the location state property.

The current logged in (auth) state of the user is retrieved from Recoil with a call to useRecoilValue(authAtom).

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

import { authAtom } from '_state';

export { PrivateRoute };

function PrivateRoute({ component: Component, ...rest }) {
    const auth = useRecoilValue(authAtom);
    return (
        <Route {...rest} render={props => {
            if (!auth) {
                // not logged in so redirect to login page with the return url
                return <Redirect to={{ pathname: '/account/login', state: { from: props.location } }} />
            }

            // authorized so return component
            return <Component {...props} />
        }} />
    );
}
 

Fake Backend

Path: /src/_helpers/fake-backend.js

In order to run and test the React + Recoil app without a real backend API, the example uses a fake backend that intercepts the HTTP requests from the React app and sends back "fake" responses. This is done by monkey patching the window.fetch() function to return fake responses for a specific set of routes.

Monkey patching is a technique used to alter the behaviour of an existing function either to extend it or change the way it works. In JavaScript this is done by storing a reference to the original function in a variable and replacing the original function with a new custom function that (optionally) calls the original function before/after executing some custom code.

The fake backend is organised into a top level handleRoute() function that checks the request url and method to determine how the request should be handled. For fake routes one of the below // route functions is called, for all other routes the request is passed through to the real backend by calling the original fetch request function (realFetch(url, opts)). Below the route functions there are // helper functions for returning different response types and performing small tasks.

export { fakeBackend };

// array in local storage for registered users
const usersKey = 'react-recoil-registration-login-example-users';
let users = JSON.parse(localStorage.getItem(usersKey)) || [];

function fakeBackend() {
    let realFetch = window.fetch;
    window.fetch = function (url, opts) {
        return new Promise((resolve, reject) => {
            // wrap in timeout to simulate server api call
            setTimeout(handleRoute, 500);

            function handleRoute() {
                switch (true) {
                    case url.endsWith('/users/authenticate') && opts.method === 'POST':
                        return authenticate();
                    case url.endsWith('/users/register') && opts.method === 'POST':
                        return register();
                    case url.endsWith('/users') && opts.method === 'GET':
                        return getUsers();
                    case url.match(/\/users\/\d+$/) && opts.method === 'GET':
                        return getUserById();
                    case url.match(/\/users\/\d+$/) && opts.method === 'PUT':
                        return updateUser();
                    case url.match(/\/users\/\d+$/) && opts.method === 'DELETE':
                        return deleteUser();
                    default:
                        // pass through any requests not handled above
                        return realFetch(url, opts)
                            .then(response => resolve(response))
                            .catch(error => reject(error));
                }
            }

            // route functions

            function authenticate() {
                const { username, password } = body();
                const user = users.find(x => x.username === username && x.password === password);

                if (!user) return error('Username or password is incorrect');

                return ok({
                    ...basicDetails(user),
                    token: 'fake-jwt-token'
                });
            }

            function register() {
                const user = body();
    
                if (users.find(x => x.username === user.username)) {
                    return error('Username "' + user.username + '" is already taken')
                }
    
                user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
                users.push(user);
                localStorage.setItem(usersKey, JSON.stringify(users));
                return ok();
            }
    
            function getUsers() {
                if (!isAuthenticated()) return unauthorized();
                return ok(users.map(x => basicDetails(x)));
            }

            function getUserById() {
                if (!isAuthenticated()) return unauthorized();
    
                const user = users.find(x => x.id === idFromUrl());
                return ok(basicDetails(user));
            }
    
            function updateUser() {
                if (!isAuthenticated()) return unauthorized();
    
                let params = body();
                let user = users.find(x => x.id === idFromUrl());
    
                // only update password if entered
                if (!params.password) {
                    delete params.password;
                }
    
                // update and save user
                Object.assign(user, params);
                localStorage.setItem(usersKey, JSON.stringify(users));
    
                return ok();
            }
    
            function deleteUser() {
                if (!isAuthenticated()) return unauthorized();
    
                users = users.filter(x => x.id !== idFromUrl());
                localStorage.setItem(usersKey, JSON.stringify(users));
                return ok();
            }
    
            // helper functions

            function ok(body) {
                resolve({ ok: true, text: () => Promise.resolve(JSON.stringify(body)) })
            }

            function unauthorized() {
                resolve({ status: 401, text: () => Promise.resolve(JSON.stringify({ message: 'Unauthorized' })) })
            }

            function error(message) {
                resolve({ status: 400, text: () => Promise.resolve(JSON.stringify({ message })) })
            }

            function basicDetails(user) {
                const { id, username, firstName, lastName } = user;
                return { id, username, firstName, lastName };
            }
    
            function isAuthenticated() {
                return opts.headers['Authorization'] === 'Bearer fake-jwt-token';
            }

            function body() {
                return opts.body && JSON.parse(opts.body);    
            }

            function idFromUrl() {
                const urlParts = url.split('/');
                return parseInt(urlParts[urlParts.length - 1]);
            }
        });
    }
}
 

Fetch Wrapper

Path: /src/_helpers/fetch-wrapper.js

The fetch wrapper is a lightweight wrapper around the native browser fetch() function used to simplify the code for making HTTP requests. It returns an object with methods for get, post, put and delete requests, it automatically handles the parsing of JSON data from responses, and throws an error if the HTTP response is not successful (!response.ok). If the response is 401 Unauthorized or 403 Forbidden the user is automatically logged out of the React + Recoil app.

The authHeader() function is used to automatically add a JWT auth token to the HTTP Authorization header of the request if the user is logged in and the request is to the application API url. The current logged in (auth) state of the user is retrieved from Recoil with a call to useRecoilState(authAtom), the setAuth() function is used in the handleResponse() function to log the user out if required.

With the fetch wrapper a POST request can be made as simply as this: fetchWrapper.post(url, body);. It's used in the example app by user actions.

import { useRecoilState } from 'recoil';

import { history } from '_helpers';
import { authAtom } from '_state';
import { useAlertActions } from '_actions';

export { useFetchWrapper };

function useFetchWrapper() {
    const [auth, setAuth] = useRecoilState(authAtom);
    const alertActions = useAlertActions();

    return {
        get: request('GET'),
        post: request('POST'),
        put: request('PUT'),
        delete: request('DELETE')
    };

    function request(method) {
        return (url, body) => {
            const requestOptions = {
                method,
                headers: authHeader(url)
            };
            if (body) {
                requestOptions.headers['Content-Type'] = 'application/json';
                requestOptions.body = JSON.stringify(body);
            }
            return fetch(url, requestOptions).then(handleResponse);
        }
    }
    
    // helper functions
    
    function authHeader(url) {
        // return auth header with jwt if user is logged in and request is to the api url
        const token = auth?.token;
        const isLoggedIn = !!token;
        const isApiUrl = url.startsWith(process.env.REACT_APP_API_URL);
        if (isLoggedIn && isApiUrl) {
            return { Authorization: `Bearer ${token}` };
        } else {
            return {};
        }
    }
    
    function handleResponse(response) {
        return response.text().then(text => {
            const data = text && JSON.parse(text);
            
            if (!response.ok) {
                if ([401, 403].includes(response.status) && auth?.token) {
                    // auto logout if 401 Unauthorized or 403 Forbidden response returned from api
                    localStorage.removeItem('user');
                    setAuth(null);
                    history.push('/account/login');
                }
    
                const error = (data && data.message) || response.statusText;
                alertActions.error(error);
                return Promise.reject(error);
            }
    
            return data;
        });
    }    
}
 

History

Path: /src/_helpers/history.js

The history helper creates the browser history object used by the React + Recoil app, it is passed to the Router component in the app component and enables access to the history object from outside of react components, for example from the logout() method of the user actions.

import { createBrowserHistory } from 'history';

const history = createBrowserHistory();

export { history };
 

Recoil Alert State

Path: /src/_state/alert.js

The alert state file contains the Recoil alert atom that is used to hold an alert notification in global state, the alert state is updated by alert actions functions.

import { atom } from 'recoil';

const alertAtom = atom({
    key: 'alert',
    default: null
});

export { alertAtom };
 

Recoil Auth State

Path: /src/_state/auth.js

The auth state file contains the Recoil auth atom that is used to hold the current logged in user in global state, the auth state is updated in the user actions login and logout functions.

The example app only contains Recoil atoms at the moment, but my idea with this file structure is that any Recoil selectors that derive from the auth atom could be placed in this file as well.

import { atom } from 'recoil';

const authAtom = atom({
    key: 'auth',
    // get initial state from local storage to enable user to stay logged in
    default: JSON.parse(localStorage.getItem('user'))
});

export { authAtom };
 

Recoil Users State

Path: /src/_state/users.js

The users state file contains the Recoil users atom that holds an array of all fetched users in global state, and the user atom that holds a single user's details, the state is updated in the user actions getAll() and getById() functions.

The example app only contains Recoil atoms at the moment, but my idea with this file structure is that any Recoil selectors that derive from users atoms could be placed in this file as well.

import { atom } from 'recoil';

const usersAtom = atom({
    key: 'users',
    default: null
});

const userAtom = atom({
    key: 'user',
    default: null
});

export { 
    usersAtom,
    userAtom
};
 

Account Component

Path: /src/account/Account.jsx

The Account component is the root component of the account section / feature, it defines routes for each of the pages within the account section which handle authentication and registration functionality.

A useEffect hook is used check if the user is already logged in when they try to access an accounts page so they can be automatically redirected to the home page ('/'), since authenticated users have no use for any of the accounts pages.

import { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { authAtom } from '_state';
import { Login, Register } from './';

export { Account };

function Account({ history, match }) {
    const auth = useRecoilValue(authAtom);
    const { path } = match;

    useEffect(() => {
        // redirect to home if already logged in
        if (auth) history.push('/');

        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return (
        <div className="container">
            <div className="row">
                <div className="col-sm-8 offset-sm-2 mt-5">
                    <Switch>
                        <Route path={`${path}/login`} component={Login} />
                        <Route path={`${path}/register`} component={Register} />
                    </Switch>
                </div>
            </div>
        </div>
    );
}
 

Login Component

Path: /src/account/Login.jsx

The login component contains a form built with the React Hook Form library that contains username and password fields for logging into the React + Recoil app.

Form validation rules are defined with the Yup schema validation library and passed with the formOptions to the React Hook Form useForm() function, for more info on Yup see https://github.com/jquense/yup.

The useForm() hook function returns an object with methods for working with a form including registering inputs, handling form submit, accessing form state, displaying errors and more, for a complete list see https://react-hook-form.com/api/useform.

The React Hook Form handleSubmit() function is called on form submit, if the form is valid it calls userActions.login() to submit the user credentials to the api by calling. On successful authentication the user auth data is stored in Recoil shared state by the login method in user actions.

The returned JSX template contains the markup for page including the form, input fields and validation messages. The form fields are registered with the React Hook Form by calling the register function with the field name from each input element (e.g. {...register('username')}). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.

import { Link } from 'react-router-dom';
import { useForm } from "react-hook-form";
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

import { useUserActions } from '_actions';

export { Login };

function Login() {
    const userActions = useUserActions();

    // form validation rules 
    const validationSchema = Yup.object().shape({
        username: Yup.string().required('Username is required'),
        password: Yup.string().required('Password is required')
    });
    const formOptions = { resolver: yupResolver(validationSchema) };

    // get functions to build form with useForm() hook
    const { register, handleSubmit, formState } = useForm(formOptions);
    const { errors, isSubmitting } = formState;

    return (
        <div className="card m-3">
            <h4 className="card-header">Login</h4>
            <div className="card-body">
                <form onSubmit={handleSubmit(userActions.login)}>
                    <div className="form-group">
                        <label>Username</label>
                        <input name="username" type="text" {...register('username')} className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.username?.message}</div>
                    </div>
                    <div className="form-group">
                        <label>Password</label>
                        <input name="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.password?.message}</div>
                    </div>
                    <button disabled={isSubmitting} className="btn btn-primary">
                        {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                        Login
                    </button>
                    <Link to="register" className="btn btn-link">Register</Link>
                </form>
            </div>
        </div>
    )
}
 

Register Component

Path: /src/account/Register.jsx

The register component contains a simple registration form built with the React Hook Form library with fields for first name, last name, username and password.

Form validation rules are defined with the Yup schema validation library and passed with the formOptions to the React Hook Form useForm() function, for more info on Yup see https://github.com/jquense/yup.

The useForm() hook function returns an object with methods for working with a form including registering inputs, handling form submit, accessing form state, displaying errors and more, for a complete list see https://react-hook-form.com/api/useform.

The onSubmit function gets called when the form is submitted and valid, and submits the form data to the api by calling userActions.register().

The returned JSX template contains the markup for page including the form, input fields and validation messages. The form fields are registered with the React Hook Form by calling the register function with the field name from each input element (e.g. {...register('username')}). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.

import { Link } from 'react-router-dom';
import { useForm } from "react-hook-form";
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

import { useUserActions, useAlertActions } from '_actions';

export { Register };

function Register({ history }) {
    const userActions = useUserActions();
    const alertActions = useAlertActions();

    // form validation rules 
    const validationSchema = Yup.object().shape({
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        username: Yup.string()
            .required('Username is required'),
        password: Yup.string()
            .required('Password is required')
            .min(6, 'Password must be at least 6 characters')
    });
    const formOptions = { resolver: yupResolver(validationSchema) };

    // get functions to build form with useForm() hook
    const { register, handleSubmit, formState } = useForm(formOptions);
    const { errors, isSubmitting } = formState;

    function onSubmit(data) {
        return userActions.register(data)
            .then(() => {
                history.push('/account/login');
                alertActions.success('Registration successful');
            })
    }

    return (
        <div className="card m-3">
            <h4 className="card-header">Register</h4>
            <div className="card-body">
                <form onSubmit={handleSubmit(onSubmit)}>
                    <div className="form-group">
                        <label>First Name</label>
                        <input name="firstName" type="text" {...register('firstName')} className={`form-control ${errors.firstName ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.firstName?.message}</div>
                    </div>
                    <div className="form-group">
                        <label>Last Name</label>
                        <input name="lastName" type="text" {...register('lastName')} className={`form-control ${errors.lastName ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.lastName?.message}</div>
                    </div>
                    <div className="form-group">
                        <label>Username</label>
                        <input name="username" type="text" {...register('username')} className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.username?.message}</div>
                    </div>
                    <div className="form-group">
                        <label>Password</label>
                        <input name="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                        <div className="invalid-feedback">{errors.password?.message}</div>
                    </div>
                    <button disabled={isSubmitting} className="btn btn-primary">
                        {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                        Register
                    </button>
                    <Link to="login" className="btn btn-link">Cancel</Link>
                </form>
            </div>
        </div>
    )
}
 

Home Component

Path: /src/home/Home.jsx

The home component is a basic react function component that displays a welcome message with the logged in user's name and a link to the users section.

The Recoil state value for the logged in user (auth) is retrieved with the the useRecoilValue() hook function.

import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { authAtom } from '_state';

export { Home };

function Home() {
    const auth = useRecoilValue(authAtom);

    return (
        <div className="p-4">
            <div className="container">
                <h1>Hi {auth?.firstName}!</h1>
                <p>You're logged in with React + Recoil & JWT!!</p>
                <p><Link to="/users">Manage Users</Link></p>
            </div>
        </div>
    );
}
 

Users Add/Edit Component

Path: /src/users/AddEdit.jsx

The users AddEdit component contains a form built with the React Hook Form library and is used for both adding and editing users.

Form validation rules are defined with the Yup schema validation library and passed with the formOptions to the React Hook Form useForm() function, for more info on Yup see https://github.com/jquense/yup.

The useForm() hook function returns an object with methods for working with a form including registering inputs, handling form submit, resetting the form, accessing form state, displaying errors and more, for a complete list see https://react-hook-form.com/api/useform.

The onSubmit function gets called when the form is submitted and valid, and either creates or updates a user depending on which mode it is in.

The form is in "add mode" when there is no user id route parameter (match.params.id), otherwise it is in "edit mode". The mode object is used to change the form behaviour based on which mode it is in, for example in "add mode" the password field is required, and in "edit mode" the user details are fetched into Recoil state when the component mounts by calling userActions.getById(id) from a useEffect() hook, another useEffect() hook that depends on the user object executes when the user is loaded into Recoil state to reset() the form with the values from the user object.

The returned JSX template contains the form with all of the input fields and validation messages. The form fields are registered with the React Hook Form by calling the register function with the field name from each input element (e.g. {...register('firstName')}). For more info on form validation with React Hook Form see React Hook Form 7 - Form Validation Example.

import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { useRecoilValue } from 'recoil';

import { userAtom } from '_state';
import { useUserActions, useAlertActions } from '_actions';

export { AddEdit };

function AddEdit({ history, match }) {
    const { id } = match.params;
    const mode = { add: !id, edit: !!id };
    const userActions = useUserActions();
    const alertActions = useAlertActions();
    const user = useRecoilValue(userAtom);

    // form validation rules 
    const validationSchema = Yup.object().shape({
        firstName: Yup.string()
            .required('First Name is required'),
        lastName: Yup.string()
            .required('Last Name is required'),
        username: Yup.string()
            .required('Username is required'),
        password: Yup.string()
            .transform(x => x === '' ? undefined : x)
            .concat(mode.add ? Yup.string().required('Password is required') : null)
            .min(6, 'Password must be at least 6 characters')
    });
    const formOptions = { resolver: yupResolver(validationSchema) };

    // get functions to build form with useForm() hook
    const { register, handleSubmit, reset, formState } = useForm(formOptions);
    const { errors, isSubmitting } = formState;

    useEffect(() => {
        // fetch user details into recoil state in edit mode
        if (mode.edit) {
            userActions.getById(id);
        }

        return userActions.resetUser;

        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    useEffect(() => {
        // set default form values after user set in recoil state (in edit mode)
        if (mode.edit && user) {
            reset(user);
        }

        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [user])

    function onSubmit(data) {
        return mode.add
            ? createUser(data)
            : updateUser(user.id, data);
    }

    function createUser(data) {
        return userActions.register(data)
            .then(() => {
                history.push('/users');
                alertActions.success('User added');
            });
    }

    function updateUser(id, data) {
        return userActions.update(id, data)
            .then(() => {
                history.push('/users');
                alertActions.success('User updated');
            });
    }

    const loading = mode.edit && !user;
    return (
        <>
            <h1>{mode.add ? 'Add User' : 'Edit User'}</h1>
            {!loading &&
                <form onSubmit={handleSubmit(onSubmit)}>
                    <div className="form-row">
                        <div className="form-group col">
                            <label>First Name</label>
                            <input name="firstName" type="text" {...register('firstName')} className={`form-control ${errors.firstName ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.firstName?.message}</div>
                        </div>
                        <div className="form-group col">
                            <label>Last Name</label>
                            <input name="lastName" type="text" {...register('lastName')} className={`form-control ${errors.lastName ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.lastName?.message}</div>
                        </div>
                    </div>
                    <div className="form-row">
                        <div className="form-group col">
                            <label>Username</label>
                            <input name="username" type="text" {...register('username')} className={`form-control ${errors.username ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.email?.message}</div>
                        </div>
                        <div className="form-group col">
                            <label>
                                Password
                                {mode.edit && <em className="ml-1">(Leave blank to keep the same password)</em>}
                            </label>
                            <input name="password" type="password" {...register('password')} className={`form-control ${errors.password ? 'is-invalid' : ''}`} />
                            <div className="invalid-feedback">{errors.password?.message}</div>
                        </div>
                    </div>
                    <div className="form-group">
                        <button type="submit" disabled={isSubmitting} className="btn btn-primary mr-2">
                            {isSubmitting && <span className="spinner-border spinner-border-sm mr-1"></span>}
                            Save
                        </button>
                        <button onClick={() => reset(user)} type="button" disabled={isSubmitting} className="btn btn-secondary">Reset</button>
                        <Link to="/users" className="btn btn-link">Cancel</Link>
                    </div>
                </form>
            }
            {loading &&
                <div className="text-center p-3">
                    <span className="spinner-border spinner-border-lg align-center"></span>
                </div>
            }
        </>
    );
}
 

Users List Component

Path: /src/users/List.jsx

The users List component displays a list of all users in the React + Recoil tutorial app and contains buttons for adding, editing and deleting users. A useEffect hook is used to load all users into Recoil shared state by calling the getAll() method of the user actions object. The users state is reset when the component unmounts to prevent a stale list of users from briefly when the component loads after the first time.

The delete button calls the userActions.delete() method which first updates the user is Recoil state with an isDeleting = true property so the UI displays a spinner on the delete button while the user is being deleted from the api.

import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { usersAtom } from '_state';
import { useUserActions } from '_actions';

export { List };

function List({ match }) {
    const { path } = match;
    const users = useRecoilValue(usersAtom);
    const userActions = useUserActions();

    useEffect(() => {
        userActions.getAll();
        
        return userActions.resetUsers;
        
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return (
        <div>
            <h1>Users</h1>
            <Link to={`${path}/add`} className="btn btn-sm btn-success mb-2">Add User</Link>
            <table className="table table-striped">
                <thead>
                    <tr>
                        <th style={{ width: '30%' }}>First Name</th>
                        <th style={{ width: '30%' }}>Last Name</th>
                        <th style={{ width: '30%' }}>Username</th>
                        <th style={{ width: '10%' }}></th>
                    </tr>
                </thead>
                <tbody>
                    {users?.map(user =>
                        <tr key={user.id}>
                            <td>{user.firstName}</td>
                            <td>{user.lastName}</td>
                            <td>{user.username}</td>
                            <td style={{ whiteSpace: 'nowrap' }}>
                                <Link to={`${path}/edit/${user.id}`} className="btn btn-sm btn-primary mr-1">Edit</Link>
                                <button onClick={() => userActions.delete(user.id)} className="btn btn-sm btn-danger" style={{ width: '60px' }} disabled={user.isDeleting}>
                                    {user.isDeleting 
                                        ? <span className="spinner-border spinner-border-sm"></span>
                                        : <span>Delete</span>
                                    }
                                </button>
                            </td>
                        </tr>
                    )}
                    {!users &&
                        <tr>
                            <td colSpan="4" className="text-center">
                                <span className="spinner-border spinner-border-lg align-center"></span>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    );
}
 

Users Component

Path: /src/users/Users.jsx

The Users component is the root component of the users section / feature, it defines routes for each of the pages within the users section.

The first route matches the root users path (/users) making it the default route for this section, so by default the users List component is displayed. The second and third routes are for adding and editing users, they match different routes but both load the users add/edit component (AddEdit) and the component modifies its behaviour based on the route.

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

import { List, AddEdit } from './';

export { Users };

function Users({ match }) {
    const { path } = match;
    
    return (
        <div className="p-4">
            <div className="container">
                <Switch>
                    <Route exact path={path} component={List} />
                    <Route path={`${path}/add`} component={AddEdit} />
                    <Route path={`${path}/edit/:id`} component={AddEdit} />
                </Switch>
            </div>
        </div>
    );
}
 

App Component

Path: /src/App.jsx

The App component is the root component of the example app, it contains the outer html, main nav and routes for the application.

All routes are restricted to authenticated users except for the account section, the private route component (PrivateRoute) is used to restrict access to routes.

The last route (<Redirect from="*" to="/" />) is a catch-all redirect route that redirects any unmatched paths to the home page.

import { Router, Route, Switch, Redirect } from 'react-router-dom';
import { useRecoilValue } from 'recoil';

import { authAtom } from '_state';
import { Nav, Alert, PrivateRoute } from '_components';
import { history } from '_helpers';
import { Home } from 'home';
import { Users } from 'users';
import { Account } from 'account';

export { App };

function App() {
    const auth = useRecoilValue(authAtom);

    return (
        <div className={'app-container' + (auth ? ' bg-light' : '')}>
            <Router history={history}>
                <Nav />
                <Alert />
                <Switch>
                    <PrivateRoute exact path="/" component={Home} />
                    <PrivateRoute path="/users" component={Users} />
                    <Route path="/account" component={Account} />
                    <Redirect from="*" to="/" />
                </Switch>
            </Router>
        </div>
    );
}
 

Global CSS Styles

Path: /src/index.css

The global stylesheet file contains CSS styles that are applied globally throughout the React application, it is imported in the main index.js file below.

a { cursor: pointer; }

.app-container {
    min-height: 350px;
}
 

Main index.js file

Path: /src/index.js

The main index.js file bootstraps the React + Recoil app by rendering the App component in the app div element located in the main index html file.

The RecoilRoot component is the context provider/store for Recoil state and is a required ancestor for any React components that access Recoil state. Wrapping it around the root App component makes Recoil state global so it's accessible to all components in the React app.

The React.StrictMode component doesn't render any elements in the UI, it runs in development mode to highlight potential issues/bugs in the React app. For more info see https://reactjs.org/docs/strict-mode.html.

Before starting the React app is started, the global CSS stylesheet is imported into the application and the fake backend API is enabled. To disable the fake backend simply remove or comment out the 2 lines below the comment // setup fake backend.

import React from 'react';
import ReactDOM from 'react-dom';
import { RecoilRoot } from 'recoil';

import 'index.css';
import { App } from 'App';

// setup fake backend
import { fakeBackend } from '_helpers';
fakeBackend();

ReactDOM.render(
    <React.StrictMode>
        <RecoilRoot>
            <App />
        </RecoilRoot>
    </React.StrictMode>,
    document.getElementById('app')
);
 

dotenv

Path: /.env

The dotenv file contains environment variables used in the example React app, the API URL is used in user actions to send HTTP requests to the API.

Environment variables set in the dotenv file that are prefixed with REACT_APP_ are accessible in the React app via process.env.<variable name> (e.g. process.env.REACT_APP_API_URL). For more info on using environment variables in React see https://create-react-app.dev/docs/adding-custom-environment-variables/

REACT_APP_API_URL=http://localhost:4000
 

jsconfig.json

Path: /jsconfig.json

The below configuration enables support for absolute imports to the application, so modules can be imported with absolute paths instead of relative paths (e.g. import { MyComponent } from '_components'; instead of import { MyComponent } from '../../../_components';).

For more info on absolute imports in React see https://create-react-app.dev/docs/importing-a-component/#absolute-imports.

{
    "compilerOptions": {
        "baseUrl": "src"
    },
    "include": ["src"]
}
 

Package.json

Path: /package.json

The package.json file contains project configuration information including package dependencies that get installed when you run npm install and scripts that are executed when you run npm start or npm run build etc. Full documentation is available at https://docs.npmjs.com/files/package.json.

{
    "name": "react-recoil-registration-login-example",
    "version": "0.1.0",
    "dependencies": {
        "@hookform/resolvers": "^2.8.0",
        "history": "^4.10.1",
        "react": "^17.0.2",
        "react-dom": "^17.0.2",
        "react-hook-form": "^7.15.1",
        "react-router-dom": "^5.3.0",
        "react-scripts": "4.0.3",
        "recoil": "^0.4.1",
        "yup": "^0.32.9"
    },
    "scripts": {
        "start": "react-scripts start",
        "build": "react-scripts build",
        "test": "react-scripts test",
        "eject": "react-scripts eject"
    },
    "eslintConfig": {
        "extends": "react-app"
    },
    "browserslist": {
        "production": [
            ">0.2%",
            "not dead",
            "not op_mini all"
        ],
        "development": [
            "last 1 chrome version",
            "last 1 firefox version",
            "last 1 safari version"
        ]
    }
}

 


Need Some React Help?

Search fiverr for freelance React developers.


Follow me for updates

On Twitter or RSS.


When I'm not coding...

Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!


Comments


Supported by