React 18 + Redux - User Registration and Login Example & Tutorial
Built with React 18.2.0, Redux 4.2.1 and Redux Toolkit 1.9.3
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 18 and Redux.
Tutorial contents
- Example app overview
- Run the React app locally
- Connect the React app to a Node.js API
- Connect the React app to an ASP.NET Core API
- React + Redux code documentation
- Other versions of this tutorial
Example React 18 + Redux App Overview
The example app contains the following pages to demonstrate login, registration and CRUD functionality:
- Login (
/account/login
) - a simple login form with username and password fields. - Register (
/account/register
) - a form to register a new account. - Home (
/
) - the home page with a welcome message displayed after successful login. - User List (
/users
) - the default page of the users section for performing CRUD operations. Displays a list of all users with buttons to add, edit and delete. - Add User (
/users/add
) - a form to create a new user. - Edit User (
/users/edit/:id
) - a form to update the user with the specified:id
.
Redux State Management with Redux Toolkit
Redux is a state management library for managing global state in a React application. The Redux Toolkit was created to simplify working with Redux and reduce the amount of boilerplate code required.
State and business logic are defined in Redux using a centralized store. With the Redux Toolkit a store is made up of one or more slices, each slice manages a section of state in the store. State is updated in Redux with actions and reducers, when an action is dispatched the Redux store executes a corresponding reducer function to update the state. Reducers cannot be called directly, they are called by Redux as the result of an action being dispatched.
For more info on Redux see https://redux.js.org. For more info on slices and the Redux Toolkit see https://redux-toolkit.js.org.
Forms built with React Hook Form Library
The forms in the example is built with React Hook Form - a library for building, validating and handling forms in React using React Hooks. I've been using it for my React projects for the last couple of years, I think it's easier to use than the other options available and requires less code. For more info see https://react-hook-form.com.
Fake Backend API
The React + Redux example app runs with a fake backend by default to enable it to run completely in the browser without a real backend API, 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.js
).
You can build your own API or hook it up with a Node.js API or .NET API available below.
Code on GitHub
The example project is available on GitHub at https://github.com/cornflourblue/react-18-redux-registration-login-example.
Here it is in action:(See on StackBlitz at https://stackblitz.com/edit/react-18-redux-registration-login-example)
Run the React 18 + Redux Login Example Locally
- Install Node.js and npm from https://nodejs.org.
- Download or clone the project source code from https://github.com/cornflourblue/react-18-redux-registration-login-example
- Install all required npm packages by running
npm install
from the command line in the project root folder (where the package.json is located). - Start the application by running
npm start
from the command line in the project root folder. - The browser should automatically launch the application at
http://localhost:3000
Connect the React 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.
- 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.
- Download or clone the project source code from https://github.com/cornflourblue/node-mysql-registration-login-api
- Install all required npm packages by running
npm install
ornpm i
from the command line in the project root folder (where the package.json is located). - Start the api by running
npm start
from the command line in the project root folder, you should see the messageServer listening on port 4000
. - Back in the React + Redux example app, remove or comment out the 2 lines below the comment
// setup fake backend
located in the/src/index.js
file, then start the React app and it should now be hooked up with the Node + MySQL API.
Node.js API with MS SQL Server or MongoDB database
The following versions of the Node API are also available: Node.js + MS SQL Server, Node + MongoDB.
Connect the React App with an ASP.NET Core API
For full details about the example .NET 6 API see the post .NET 6.0 - User Registration and Login Tutorial with Example API. But to get up and running quickly just follow the below steps.
- Install the .NET Core SDK from https://dotnet.microsoft.com/download.
- Download or clone the project source code from https://github.com/cornflourblue/dotnet-6-registration-login-api
- 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 messageNow listening on: http://localhost:4000
. - Back in the React 18 + Redux example app, remove or comment out the 2 lines below the comment
// setup fake backend
located in the/src/index.js
file, then start the React app and it should now be hooked up with the .NET API.
React 18 + Redux Code Documentation
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:
- _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. - _store
Redux store and slices that define the global state available to the React application. Each slice contains actions and reducers that are responsible for updating global state. For more info on Redux see https://redux.js.org. - account
Account page components for register and login - home
Home page components - users
Users page components for CRUD operations
Folder naming convention
Each feature has its own folder (accuont, home & users), other shared/common code such as components, helpers, store 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 code 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.
Barrel files
The index.js
file in each folder are barrel files that re-export 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, PrivateRoute } from '_components';
).
The /_store/index.js
file also configures and exports the centralized Redux store
which is provided to the React app in the main index.js file on startup.
Base URL for imports
The baseUrl
is set to "src"
in the jsconfig.json file to make all import statements (without a dot '.' prefix) relative to the /src
folder of the project, removing the need for long relative paths like import { history } from '../../../_helpers';
.
Click any of the below links to jump down to a description of each file along with its code:
- public
- src
- _components
- Alert.jsx
- Nav.jsx
- PrivateRoute.jsx
- index.js
- _helpers
- fake-backend.js
- fetch-wrapper.js
- history.js
- index.js
- _store
- account
- AccountLayout.jsx
- Login.jsx
- Register.jsx
- index.js
- home
- Home.jsx
- index.js
- users
- AddEdit.jsx
- List.jsx
- UsersLayout.jsx
- index.js
- App.jsx
- index.css
- index.js
- _components
- .env
- jsconfig.json
- package.json
Main Index Html File
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 18 + Redux - User Registration and Login Example & Tutorial</title>
<!-- bootstrap css -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
React Alert Component
The alert component renders the alert from Redux state with bootstrap CSS classes, if the Redux alert
state property contains a null
value nothing is rendered for the component.
The component automatically clears the alert on location change with a useEffect()
hook that has a dependency on the location
object. For more info on how this works see React Router v6 - Listen to location (route) change without history.listen.
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { alertActions } from '_store';
export { Alert };
function Alert() {
const dispatch = useDispatch();
const location = useLocation();
const alert = useSelector(x => x.alert.value);
useEffect(() => {
// clear alert on location change
dispatch(alertActions.clear());
}, [location]);
if (!alert) return null;
return (
<div className="container">
<div className="m-3">
<div className={`alert alert-dismissible ${alert.type}`}>
{alert.message}
<button type="button" className="btn-close" onClick={() => dispatch(alertActions.clear())}></button>
</div>
</div>
</div>
);
}
React Nav Component
The nav component displays the primary bar in the example. The component gets the current auth
data from global Redux state with the useSelector()
hook 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 { useSelector, useDispatch } from 'react-redux';
import { authActions } from '_store';
export { Nav };
function Nav() {
const auth = useSelector(x => x.auth.value);
const dispatch = useDispatch();
const logout = () => dispatch(authActions.logout());
// only show nav when logged in
if (!auth) return null;
return (
<nav className="navbar navbar-expand navbar-dark bg-dark px-3">
<div className="navbar-nav">
<NavLink to="/" className="nav-item nav-link">Home</NavLink>
<NavLink to="/users" className="nav-item nav-link">Users</NavLink>
<button onClick={logout} className="btn btn-link nav-item nav-link">Logout</button>
</div>
</nav>
);
}
React Private Route
The react private route component renders child routes with the <Outlet />
component if the user is logged in. If not logged in the user is redirected to the /account/login
page with the <Navigate />
component, the return url is passed in the location state
property.
The current logged in user (auth.value
) is retrieved from Redux with the useSelector()
hook.
import { Navigate, Outlet } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { history } from '_helpers';
export { PrivateRoute };
function PrivateRoute() {
const auth = useSelector(x => x.auth.value);
if (!auth) {
// not logged in so redirect to login page with the return url
return <Navigate to="/account/login" state={{ from: history.location }} />
}
// authorized so return outlet for child routes
return <Outlet />;
}
Fake Backend API
In order to run and test the React + Redux 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
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-18-redux-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;
}
// if username changed check if taken
if (params.username !== user.username && users.find(x => x.username === params.username)) {
return error('Username "' + params.username + '" is already taken')
}
// 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, ...headers(), json: () => Promise.resolve(body) })
}
function unauthorized() {
resolve({ status: 401, ...headers(), json: () => Promise.resolve({ message: 'Unauthorized' }) })
}
function error(message) {
resolve({ status: 400, ...headers(), json: () => Promise.resolve({ 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]);
}
function headers() {
return {
headers: {
get(key) {
return ['application/json'];
}
}
}
}
});
}
}
JavaScript Fetch Wrapper
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 + Redux 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 authToken()
function returns the JWT token for the current logged in user, or null if not logged in. The token is retreived from Redux using store.getState()
instead of the useSelector()
hook because React hook functions can only be called from inside React components or other hook functions.
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 in the Redux auth slice and users slice.
import { store, authActions } from '_store';
export const fetchWrapper = {
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 = authToken();
const isLoggedIn = !!token;
const isApiUrl = url.startsWith(process.env.REACT_APP_API_URL);
if (isLoggedIn && isApiUrl) {
return { Authorization: `Bearer ${token}` };
} else {
return {};
}
}
function authToken() {
return store.getState().auth.value?.token;
}
async function handleResponse(response) {
const isJson = response.headers?.get('content-type')?.includes('application/json');
const data = isJson ? await response.json() : null;
// check for error response
if (!response.ok) {
if ([401, 403].includes(response.status) && authToken()) {
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
const logout = () => store.dispatch(authActions.logout());
logout();
}
// get error message from body or default to response status
const error = (data && data.message) || response.status;
return Promise.reject(error);
}
return data;
}
React Router History Helper
The history helper is a plain javascript object to enable access to the React Router navigate()
function and location
property from anywhere in the React app including outside components. It's required in this example to enable navigation on login and logout from the Redux auth slice.
The navigate
and location
properties are initialized on app startup in the root App component with the React Router useNavigate()
and useLocation()
hooks, because React hook functions can only be called from inside React components or other hook functions.
// custom history object to allow navigation outside react components
export const history = {
navigate: null,
location: null
};
Redux Alert Slice
The alert slice manages Redux state, actions and reducers for alert notifications. The file is organised into three sections to make it easier to see what's going on. The first section (// create slice
) calls functions to create and configure the Redux slice, the second section exports the actions and reducer, and the third section contains the functions that implement the logic.
initialState
defines the state properties for the 'alert'
slice with initial values. The value
state property holds the current alert notification, when it contains an object the alert is rendered at the top of the screen by the alert component, when the value is null
no alert is displayed.
The reducers
functions contain logic to update state for different actions (success(), error(), clear()
). The Redux Toolkit createSlice()
function auto generates matching actions for these reducers and exposes them via the slice.actions
property.
Export Actions and Reducer for Redux Slice
The alertActions
export includes all actions (slice.actions
) for the alert slice.
The reducer for the alert slice is exported as alertReducer
, which is used in the Redux store for the app to configure the global state store.
import { createSlice } from '@reduxjs/toolkit';
// create slice
const name = 'alert';
const initialState = createInitialState();
const reducers = createReducers();
const slice = createSlice({ name, initialState, reducers });
// exports
export const alertActions = { ...slice.actions };
export const alertReducer = slice.reducer;
// implementation
function createInitialState() {
return {
value: null
}
}
function createReducers() {
return {
success,
error,
clear
};
// payload can be a string message ('alert message') or
// an object ({ message: 'alert message', showAfterRedirect: true })
function success(state, action) {
state.value = {
type: 'alert-success',
message: action.payload?.message || action.payload,
showAfterRedirect: action.payload?.showAfterRedirect
};
}
function error(state, action) {
state.value = {
type: 'alert-danger',
message: action.payload?.message || action.payload,
showAfterRedirect: action.payload?.showAfterRedirect
};
}
function clear(state) {
// if showAfterRedirect flag is true the alert is not cleared
// for one route change (e.g. after successful registration)
if (state.value?.showAfterRedirect) {
state.value.showAfterRedirect = false;
} else {
state.value = null;
}
}
}
Redux Auth Slice
The auth slice manages Redux state, actions and reducers for authentication. The file is organised into three sections to make it easier to see what's going on. The first section calls functions to create and configure the slice, the second section exports the actions and reducer, and the third section contains the functions that implement the logic.
initialState
defines the state properties in the slice with their initial values. The value
state property holds the current authenticated user, it is initialized with the 'auth'
object from local storage to support staying logged in between page refreshes and browser sessions, or null
if localStorage is empty.
The reducers
functions contain logic to update state for synchronous actions (setAuth()
). The Redux Toolkit createSlice()
function auto generates matching actions for these reducers and exposes them via the slice.actions
property.
Async Actions with createAsyncThunk()
The extraActions
object contains logic for asynchronous actions (things you have to wait for) such as API requests, as well as actions with side effects like redirecting and accessing local storage. These actions are created with the Redux Toolkit createAsyncThunk()
function.
The login()
action method posts credentials to the API, on success the returned user object is stored in the Redux state auth
prop by calling dispatch(authActions.setAuth(user));
, the object is saved in localStorage, and the user is redirected to the return url or home page. On fail the error is displayed by calling dispatch(alertActions.error(error));
.
Export Actions and Reducer for Redux Slice
The authActions
export includes all sync actions (slice.actions
) and async actions (extraActions
) for the auth slice.
The reducer for the auth slice is exported as authReducer
, which is used in the Redux store for the app to configure the global state store.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { alertActions } from '_store';
import { history, fetchWrapper } from '_helpers';
// create slice
const name = 'auth';
const initialState = createInitialState();
const reducers = createReducers();
const extraActions = createExtraActions();
const slice = createSlice({ name, initialState, reducers });
// exports
export const authActions = { ...slice.actions, ...extraActions };
export const authReducer = slice.reducer;
// implementation
function createInitialState() {
return {
// initialize state from local storage to enable user to stay logged in
value: JSON.parse(localStorage.getItem('auth'))
}
}
function createReducers() {
return {
setAuth
};
function setAuth(state, action) {
state.value = action.payload;
}
}
function createExtraActions() {
const baseUrl = `${process.env.REACT_APP_API_URL}/users`;
return {
login: login(),
logout: logout()
};
function login() {
return createAsyncThunk(
`${name}/login`,
async function ({ username, password }, { dispatch }) {
dispatch(alertActions.clear());
try {
const user = await fetchWrapper.post(`${baseUrl}/authenticate`, { username, password });
// set auth user in redux state
dispatch(authActions.setAuth(user));
// store user details and jwt token in local storage to keep user logged in between page refreshes
localStorage.setItem('auth', JSON.stringify(user));
// get return url from location state or default to home page
const { from } = history.location.state || { from: { pathname: '/' } };
history.navigate(from);
} catch (error) {
dispatch(alertActions.error(error));
}
}
);
}
function logout() {
return createAsyncThunk(
`${name}/logout`,
function (arg, { dispatch }) {
dispatch(authActions.setAuth(null));
localStorage.removeItem('auth');
history.navigate('/account/login');
}
);
}
}
Redux Users Slice
The users slice manages Redux state, actions and reducers for users in the React app. Each part of the slice is organised into its own function that is called from the top of the file to make it easier to see what's going on. initialState
defines the state properties in this slice with their initial values. The list
property is for storing all users fetched from the API and item
is for storing a single user. They both default to null
but can hold the following values:
list
null
- initial state.{ loading: true }
- user list currently being fetched from the API.{ value: [{...}, {...}] }
- array of users returned by the API.{ error: { message: 'an error message' } }
- request to the API failed and an error was returned.
item
null
- initial state.{ loading: true }
- single user is currently being fetched from the API.{ value: {...} }
- user object returned by the API.{ error: { message: 'an error message' } }
- request to the API failed and an error was returned.
Async Actions with createAsyncThunk()
The extraActions
object contains logic for asynchronous actions (things you have to wait for) such as API requests. Async actions are created with the Redux Toolkit createAsyncThunk()
function. The extraReducers
object contains methods for updating Redux state at different stages of async actions (pending, fulfilled, rejected), and is passed as a parameter to the createSlice()
function.
The Redux register(user)
action method sends a POST
request to the API to create a new user.
The getAll()
action method fetches the users from the API and updates the list
state property based on the result.
The getById(id)
action method fetches a specific user by id from the API and updates the item
Redux state property based on the result.
The update({ id, data })
action method sends a PUT
request to the API to update the included data
of the user with the specified id
. If the current logged in user updates their own record, the changes are synced with Redux auth
state by dispatching authActions.setAuth(user)
.
The Redux delete(id)
action method sends a DELETE
request to the API to delete the user with the specified id
. If the current user deletes their own record they are automatically logged out of the app by the call the dispatch(authActions.logout());
.
Export Actions and Reducer for Redux Slice
The userActions
export includes all sync actions (slice.actions
) and async actions (extraActions
) for the users slice.
The reducer for the users slice is exported as usersReducer
, which is used in the root Redux store to configure global state for the React app.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { authActions } from '_store';
import { fetchWrapper } from '_helpers';
// create slice
const name = 'users';
const initialState = createInitialState();
const extraActions = createExtraActions();
const extraReducers = createExtraReducers();
const slice = createSlice({ name, initialState, extraReducers });
// exports
export const userActions = { ...slice.actions, ...extraActions };
export const usersReducer = slice.reducer;
// implementation
function createInitialState() {
return {
list: null,
item: null
}
}
function createExtraActions() {
const baseUrl = `${process.env.REACT_APP_API_URL}/users`;
return {
register: register(),
getAll: getAll(),
getById: getById(),
update: update(),
delete: _delete()
};
function register() {
return createAsyncThunk(
`${name}/register`,
async (user) => await fetchWrapper.post(`${baseUrl}/register`, user)
);
}
function getAll() {
return createAsyncThunk(
`${name}/getAll`,
async () => await fetchWrapper.get(baseUrl)
);
}
function getById() {
return createAsyncThunk(
`${name}/getById`,
async (id) => await fetchWrapper.get(`${baseUrl}/${id}`)
);
}
function update() {
return createAsyncThunk(
`${name}/update`,
async function ({ id, data }, { getState, dispatch }) {
await fetchWrapper.put(`${baseUrl}/${id}`, data);
// update stored user if the logged in user updated their own record
const auth = getState().auth.value;
if (id === auth?.id.toString()) {
// update local storage
const user = { ...auth, ...data };
localStorage.setItem('auth', JSON.stringify(user));
// update auth user in redux state
dispatch(authActions.setAuth(user));
}
}
);
}
// prefixed with underscore because delete is a reserved word in javascript
function _delete() {
return createAsyncThunk(
`${name}/delete`,
async function (id, { getState, dispatch }) {
await fetchWrapper.delete(`${baseUrl}/${id}`);
// auto logout if the logged in user deleted their own record
if (id === getState().auth.value?.id) {
dispatch(authActions.logout());
}
}
);
}
}
function createExtraReducers() {
return (builder) => {
getAll();
getById();
_delete();
function getAll() {
var { pending, fulfilled, rejected } = extraActions.getAll;
builder
.addCase(pending, (state) => {
state.list = { loading: true };
})
.addCase(fulfilled, (state, action) => {
state.list = { value: action.payload };
})
.addCase(rejected, (state, action) => {
state.list = { error: action.error };
});
}
function getById() {
var { pending, fulfilled, rejected } = extraActions.getById;
builder
.addCase(pending, (state) => {
state.item = { loading: true };
})
.addCase(fulfilled, (state, action) => {
state.item = { value: action.payload };
})
.addCase(rejected, (state, action) => {
state.item = { error: action.error };
});
}
function _delete() {
var { pending, fulfilled, rejected } = extraActions.delete;
builder
.addCase(pending, (state, action) => {
const user = state.list.value.find(x => x.id === action.meta.arg);
user.isDeleting = true;
})
.addCase(fulfilled, (state, action) => {
state.list.value = state.list.value.filter(x => x.id !== action.meta.arg);
})
.addCase(rejected, (state, action) => {
const user = state.list.value.find(x => x.id === action.meta.arg);
user.isDeleting = false;
});
}
}
}
Redux Store
The store index file configures the root Redux store for the React application with the configureStore()
function. The returned Redux store contains the state properties alert
, auth
and users
which map to their corresponding slices.
The index file also re-exports all of the modules from the Redux slices in the folder. This enables Redux modules to be imported directly from the _store
folder without the path to the slice file. It also enables multiple imports from different files at once (e.g. import { store, authActions } from '_store';
)
import { configureStore } from '@reduxjs/toolkit';
import { alertReducer } from './alert.slice';
import { authReducer } from './auth.slice';
import { usersReducer } from './users.slice';
export * from './alert.slice';
export * from './auth.slice';
export * from './users.slice';
export const store = configureStore({
reducer: {
alert: alertReducer,
auth: authReducer,
users: usersReducer
},
});
Account Layout Component
The AccountLayout
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.
If the user is already logged in when they try to access an accounts page they are automatically redirected to the home page ('/'
) with a <Navigate />
component, since authenticated users have no use for any of the accounts pages.
import { Routes, Route, Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { Login, Register } from './';
export { AccountLayout };
function AccountLayout() {
const auth = useSelector(x => x.auth.value);
// redirect to home if already logged in
if (auth) {
return <Navigate to="/" />;
}
return (
<div className="container">
<div className="row">
<div className="col-sm-8 offset-sm-2 mt-5">
<Routes>
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
</Routes>
</div>
</div>
</div>
);
}
React Login Component
The login page contains a form built with the React Hook Form library that contains username and password fields for logging into the React + Redux app.
Form validation rules are defined with the Yup schema validation library and passed with 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 user credentials to the api by calling dispatch(authActions.login({ username, password }))
. On successful authentication the user data with JWT token is stored in Redux shared state by the setAuth
reducer in the auth slice.
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 { useDispatch } from 'react-redux';
import { authActions } from '_store';
export { Login };
function Login() {
const dispatch = useDispatch();
// 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;
function onSubmit({ username, password }) {
return dispatch(authActions.login({ username, password }));
}
return (
<div className="card m-3">
<h4 className="card-header">Login</h4>
<div className="card-body">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label className="form-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="mb-3">
<label className="form-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 me-1"></span>}
Login
</button>
<Link to="../register" className="btn btn-link">Register</Link>
</form>
</div>
</div>
)
}
Register Component
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()
. On success the user is redirected to the login page with a success alert notification.
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 { useDispatch } from 'react-redux';
import { history } from '_helpers';
import { userActions, alertActions } from '_store';
export { Register };
function Register() {
const dispatch = useDispatch();
// 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;
async function onSubmit(data) {
dispatch(alertActions.clear());
try {
await dispatch(userActions.register(data)).unwrap();
// redirect to login page and display success alert
history.navigate('/account/login');
dispatch(alertActions.success({ message: 'Registration successful', showAfterRedirect: true }));
} catch (error) {
dispatch(alertActions.error(error));
}
}
return (
<div className="card m-3">
<h4 className="card-header">Register</h4>
<div className="card-body">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-3">
<label className="form-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="mb-3">
<label className="form-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="mb-3">
<label className="form-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="mb-3">
<label className="form-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 me-1"></span>}
Register
</button>
<Link to="../login" className="btn btn-link">Cancel</Link>
</form>
</div>
</div>
)
}
Home Component
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 Redux state value for the logged in user (auth.value
) is retrieved with the the useSelector()
hook function.
import { Link } from 'react-router-dom';
import { useSelector } from 'react-redux';
export { Home };
function Home() {
const auth = useSelector(x => x.auth.value);
return (
<div>
<h1>Hi {auth?.firstName}!</h1>
<p>You're logged in with React 18 + Redux & JWT!!</p>
<p><Link to="/users">Manage Users</Link></p>
</div>
);
}
Users Add/Edit Component
The users AddEdit
component contains a form built with the React Hook Form library and is used for both adding and editing users. The component determines if it's in add or edit mode by checking if there's a user id in the route parameters.
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 edit mode when there a user id route parameter, otherwise it is in add mode. In edit mode the user details are fetched into Redux state when the component loads by calling dispatch(userActions.getById(id))
and preloaded into the form fields with the React Hook Form reset function (reset(user)
). Also the password field is required in add mode and optional in edit mode.
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, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { useSelector, useDispatch } from 'react-redux';
import { history } from '_helpers';
import { userActions, alertActions } from '_store';
export { AddEdit };
function AddEdit() {
const { id } = useParams();
const [title, setTitle] = useState();
const dispatch = useDispatch();
const user = useSelector(x => x.users?.item);
// 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)
// password optional in edit mode
.concat(id ? null : 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, reset, formState } = useForm(formOptions);
const { errors, isSubmitting } = formState;
useEffect(() => {
if (id) {
setTitle('Edit User');
// fetch user details into redux state and
// populate form fields with reset()
dispatch(userActions.getById(id)).unwrap()
.then(user => reset(user));
} else {
setTitle('Add User');
}
}, []);
async function onSubmit(data) {
dispatch(alertActions.clear());
try {
// create or update user based on id param
let message;
if (id) {
await dispatch(userActions.update({ id, data })).unwrap();
message = 'User updated';
} else {
await dispatch(userActions.register(data)).unwrap();
message = 'User added';
}
// redirect to user list with success message
history.navigate('/users');
dispatch(alertActions.success({ message, showAfterRedirect: true }));
} catch (error) {
dispatch(alertActions.error(error));
}
}
return (
<>
<h1>{title}</h1>
{!(user?.loading || user?.error) &&
<form onSubmit={handleSubmit(onSubmit)}>
<div className="row">
<div className="mb-3 col">
<label className="form-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="mb-3 col">
<label className="form-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="row">
<div className="mb-3 col">
<label className="form-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="mb-3 col">
<label className="form-label">
Password
{id && <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="mb-3">
<button type="submit" disabled={isSubmitting} className="btn btn-primary me-2">
{isSubmitting && <span className="spinner-border spinner-border-sm me-1"></span>}
Save
</button>
<button onClick={() => reset()} type="button" disabled={isSubmitting} className="btn btn-secondary">Reset</button>
<Link to="/users" className="btn btn-link">Cancel</Link>
</div>
</form>
}
{user?.loading &&
<div className="text-center m-5">
<span className="spinner-border spinner-border-lg align-center"></span>
</div>
}
{user?.error &&
<div class="text-center m-5">
<div class="text-danger">Error loading user: {user.error}</div>
</div>
}
</>
);
}
Users List Component
The users List
component displays a list of all users in the React + Redux tutorial app and contains buttons for adding, editing and deleting users. A useEffect
hook is used to load all users into Redux shared state by dispatching the userActions.getAll()
Redux action.
The delete button dispatches userActions.delete()
which first updates the user is Redux state with an isDeleting = true
property so the UI displays a spinner on the delete button while the delete request in pending.
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { userActions } from '_store';
export { List };
function List() {
const users = useSelector(x => x.users.list);
const dispatch = useDispatch();
useEffect(() => {
dispatch(userActions.getAll());
}, []);
return (
<div>
<h1>Users</h1>
<Link to="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?.value?.map(user =>
<tr key={user.id}>
<td>{user.firstName}</td>
<td>{user.lastName}</td>
<td>{user.username}</td>
<td style={{ whiteSpace: 'nowrap' }}>
<Link to={`edit/${user.id}`} className="btn btn-sm btn-primary me-1">Edit</Link>
<button onClick={() => dispatch(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?.loading &&
<tr>
<td colSpan="4" className="text-center">
<span className="spinner-border spinner-border-lg align-center"></span>
</td>
</tr>
}
</tbody>
</table>
</div>
);
}
Users Layout Component
The UsersLayout
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 (index
) 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 { Routes, Route } from 'react-router-dom';
import { List, AddEdit } from './';
export { UsersLayout };
function UsersLayout() {
return (
<div className="p-4">
<div className="container">
<Routes>
<Route index element={<List />} />
<Route path="add" element={<AddEdit />} />
<Route path="edit/:id" element={<AddEdit />} />
</Routes>
</div>
</div>
);
}
App Component
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 (<Route path="*" element={<Navigate to="/" />} />) is a catch-all redirect route that redirects any unmatched paths to the home page.
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { history } from '_helpers';
import { Nav, Alert, PrivateRoute } from '_components';
import { Home } from 'home';
import { AccountLayout } from 'account';
import { UsersLayout } from 'users';
export { App };
function App() {
// init custom history object to allow navigation from
// anywhere in the react app (inside or outside components)
history.navigate = useNavigate();
history.location = useLocation();
return (
<div className="app-container bg-light">
<Nav />
<Alert />
<div className="container pt-4 pb-4">
<Routes>
{/* private */}
<Route element={<PrivateRoute />}>
<Route path="/" element={<Home />} />
<Route path="users/*" element={<UsersLayout />} />
</Route>
{/* public */}
<Route path="account/*" element={<AccountLayout />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</div>
);
}
Global CSS Styles
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.
.app-container {
min-height: 350px;
}
Main index.js file
The main index.js file bootstraps the React + Redux app by rendering the App
component in the root
div element located in the main index html file.
The Provider
component is the context provider for Redux state and is a required ancestor for any React components that access Redux state. Wrapping it around the root App
component makes the Redux store
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.
BrowserRouter
adds support for Routes
and React Router 6 features to any component in the app.
Before the React app is started, the global CSS stylesheet (./index.css
) is imported 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 { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { store } from './_store';
import { App } from './App';
import './index.css';
// setup fake backend
import { fakeBackend } from './_helpers';
fakeBackend();
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>
);
dotenv
The dotenv file contains environment variables used in the example React app, the API URL is used in the Redux auth slice and users slice 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 React - Access Environment Variables from dotenv (.env)
REACT_APP_API_URL=http://localhost:4000
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
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.
react-hooks/exhaustive-deps disabled globally
I disabled the ESLint react-hooks/exhaustive-deps rule globally here with "react-hooks/exhaustive-deps": "off"
to remove multiple warnings on useEffect()
hooks with intentional empty dependency arrays. I know some people aren't a fan of disabling this rule but I prefer it over adding unnecessary dependencies or code to my hooks just to satisfy the rule, and I think it's cleaner than adding the comment // eslint-disable-next-line react-hooks/exhaustive-deps
to multiple places.
Full documentation on package.json is available at https://docs.npmjs.com/files/package.json.
{
"name": "react-18-redux-registration-login-example",
"version": "0.1.0",
"private": true,
"dependencies": {
"@hookform/resolvers": "^2.9.0",
"@reduxjs/toolkit": "^1.8.2",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-hook-form": "^7.31.3",
"react-redux": "^8.0.2",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"yup": "^0.32.11"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [ "react-app" ],
"rules": {
"react-hooks/exhaustive-deps": "off"
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
Other versions of this tutorial
The user registration and login tutorial is also available in the following versions:
- React: React 17 + Recoil, React 16 Hooks + Redux, React 16 + Redux
- Next.js: Next.js 13 (App Router) + MongoDB, Next.js 13 (Pages Router) + MongoDB, Next.js 13 (Pages Router) + MySQL, Next.js 11
- Angular: Angular 15/16, 14, 10, 9, 8, 7, 6, 2/5
- Vue: Vue 3 + Pinia, Vue 2 + Vuex
- AngularJS: AngularJS
- .NET: Blazor WebAssembly
Need Some React Help?
Search fiverr for freelance React developers.
Follow me for updates
When I'm not coding...
Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!