Skip to main content

Command Palette

Search for a command to run...

Building a ShelfLife Household Inventory Tracker — Frontend

Updated
50 min read
Building a ShelfLife Household Inventory Tracker — Frontend
S

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.

In the previous blog, we designed and built the backend architecture for ShelfLife — a collaborative household inventory tracking application focused on reducing food waste.

Backend Blog: https://shubhamsinghbundela.hashnode.dev/building-a-shelflife-household-inventory-tracker-backend

Now it’s time to build the frontend application that users will interact with daily.


Why ShelfLife?

In many shared households:

  • Multiple people buy groceries

  • Items are stored in different places

  • Expiry dates get ignored

  • Food gets wasted

ShelfLife aims to solve this problem collaboratively.


Step 1: Setting Up the Project with Vite

We start by creating the React application using Vite.

Why Vite?

Because modern frontend development requires fast feedback loops and better developer experience.

Compared to older tooling like Create React App, Vite provides:

  • Extremely fast startup time

  • Instant Hot Module Replacement (HMR)

  • Lightweight configuration

  • Faster build.


Create the project:

npm create vite@latest

Start the development server:

npm run dev

Step2: Deciding the Folder Structure

One of the most important frontend decisions is folder organization.

I followed ideas inspired by https://www.joshwcomeau.com/react/file-structure/ and adapted them for this project.

Current structure:

src/
 ├── assets/
 ├── components/
 │    ├── auth/
 │    │     └── Login.jsx
 │    │
 │    ├── dashboard/
 │    │     └── Dashboard.jsx
 │
 ├── routes/
 │    └── index.js
 │
 ├── App.jsx
 └── main.jsx

Why This Structure?

Instead of grouping everything by file type only, I grouped components by feature.

Benefits:

  • Better scalability

  • Components remain discoverable

  • Features stay isolated

For example:

components/auth

contains all authentication-related components.

Similarly:

components/dashboard

contains dashboard-related components.

This structure becomes extremely helpful as the application grows.


Step3 : Understanding @ Alias Imports in React + Vite

In this project, I’m using the @ alias everywhere instead of relative paths, so before moving forward, let’s understand what @ actually means ?

@ is simply an alias that points to the src folder.

Meaning:
@ -> src

So:

@/components/auth/Login

actually means:

src/components/auth/Login

Why Use Alias Imports?

Without aliases, imports can become messy very quickly.

Example:

../../../components/auth/Login

As projects grow, relative paths become difficult to read and maintain.

Using aliases keeps imports clean and predictable:

@/components/auth/Login

Benefits:

  • Cleaner imports

  • Easier refactoring

  • Better readability

  • Simpler navigation in large projects

Configuring @ Alias in Vite

To make alias imports work, we need to configure Vite.

Inside:

vite.config.js

add:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],

  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

Understanding the Configuration

"@": path.resolve(__dirname, "./src")

means:

Whenever React sees "@",
replace it with the src folder path.

So:

@/components/dashboard/Dashboard

becomes:

src/components/dashboard/Dashboard

automatically.


Step 4: Installing React Router DOM

Since ShelfLife is a multi-page application, we need routing.

Install React Router DOM:

npm install react-router-dom

React Router DOM helps us create client-side routing without full page refreshes.

Example:

/         -> Join Household Page
/items    -> Items Dashboard

Before setting up routes, I had to decide which routing approach I wanted to use.

React Router provides multiple routing modes, but for ShelfLife I decided to use:

<BrowserRouter>

which is part of React Router’s Declarative Mode.


Why I Chose BrowserRouter

ShelfLife is primarily a dashboard-style application where most pages are accessed after authentication.

For this kind of application, client-side routing works extremely well because:

  • Navigation feels instant

  • No full page refreshes

  • Simpler project structure

  • Easier mental model

  • Perfect for SPA (Single Page Applications)

I did not need advanced framework features like:

  • Server Side Rendering (SSR)

  • Route loaders/actions

  • Full-stack routing

So using BrowserRouter kept the architecture simple and predictable.

The routing flow looks like:

BrowserRouter
   ↓
Routes
   ↓
Outlet
   ↓
Child Pages

Step 5: Setting Up Centralized Routing

I decided to use centralized route configuration.

Why?

Because I wanted:

  • All routes visible in one place

  • Simpler mental model

  • Predictable architecture

  • Better scalability


Creating the Route Configuration

Inside:

src/routes/index.js

we create all application routes.

import { lazy } from "react";

export const routes = [
  {
    path: "",
    component: lazy(() =>
      import("@/components/joinHouseHold/JoinHouseHold")
    ),
  },
  {
    path: "/items",
    component: lazy(() =>
      import("@/components/dashboard/items")
    ),
  },
];

Understanding lazy()

lazy(() => import(...))

is React’s implementation of lazy loading.

Lazy loading is a performance optimization technique where components are downloaded only when they are actually needed instead of loading everything upfront.

This means components load only when required.

Example:

  • JoinHouseHold component loads only when user visits /

  • Items component loads only when user visits /items

Benefits:

  • Smaller initial bundle size

  • Better performance

  • Faster initial page load

  • Reduced unnecessary JavaScript downloads


Step 6: Creating a Shared Layout Route

Most pages inside ShelfLife share the same layout structure:

  • Navbar

  • Main Content

  • Footer

Instead of repeating these components inside every page, I created a reusable layout route.

Inside:

src/components/main/Body.jsx
import { Outlet } from "react-router-dom";
import Footer from "./Footer";
import Navbar from "./Navbar";

const Body = () => {
  return (
    <div
      style={{
        minHeight: "100vh",
        display: "flex",
        flexDirection: "column",
      }}
    >
      <Navbar />

      <div style={{ flex: 1 }}>
        <Outlet />
      </div>

      <Footer />
    </div>
  );
};

export default Body;

Understanding Outlet

<Outlet />

is one of the most important concepts in React Router.

It acts as a placeholder where child routes render dynamically.

Example:

/        -> JoinHouseHold renders inside Outlet
/items   -> Items page renders inside Outlet

This allows us to create reusable layouts very easily.

The final layout structure becomes:

Navbar
   ↓
Outlet (Dynamic Page Content)
   ↓
Footer

Step 7: Configuring the App Router

Inside:

src/App.jsx
import { Suspense } from "react";
import "@/App.css";

import {
  BrowserRouter,
  Routes,
  Route,
} from "react-router-dom";

import Body from "@/components/main/Body";
import { routes } from "@/routes";

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<h1>Loading...</h1>}>
        <Routes>
          <Route path="/" element={<Body />}>
            {routes.map((route) => {
              const Component = route.component;

              return (
                <Route
                  key={route.path}
                  path={route.path}
                  element={<Component />}
                />
              );
            })}
          </Route>
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

export default App;

Understanding BrowserRouter

<BrowserRouter>

enables browser-based client-side routing using URLs.

Without it, React Router cannot manage page navigation.


Understanding Routes

<Routes>

acts as a container for all route definitions.

Understanding Route

<Route path="/items" element={<Items />} />

means:

/items -> render Items component

Understanding Suspense

Since we are using lazy loading, React needs a fallback UI while components are downloading.

<Suspense fallback={<h1>Loading...</h1>}>

shows loading content until the lazy-loaded component becomes available.


Step 8: Setting Up Material UI

Now that the routing architecture was ready, the next step was building the actual user interface for ShelfLife.

For the frontend UI library, I decided to use Material UI.


Why Material UI?

Because ShelfLife is a dashboard-oriented application and Material UI provides:

  • Prebuilt production-ready components

  • Consistent design system

  • Faster UI development

  • Excellent form components

  • Responsive layouts

  • Theme customization support

This allows us to focus more on application logic instead of spending excessive time building UI components from scratch.


Installing Material UI

To install Material UI and its required styling dependencies:

npm install @mui/material @emotion/react @emotion/styled

Understanding Emotion

You may notice two additional packages:

@emotion/react
@emotion/styled

Material UI uses Emotion internally as its styling engine.

Emotion helps Material UI:

  • Generate dynamic styles

  • Handle component-based styling

  • Support theming

  • Optimize CSS performance

Without these packages, Material UI components will not work properly.


Creating the Global Theme

Since ShelfLife is focused on freshness tracking and inventory management, I wanted the application to have a green-themed design system representing freshness and sustainability.

Inside:

src/theme.js

we create a global Material UI theme.

import { createTheme } from "@mui/material/styles";

const theme = createTheme({
  palette: {
    primary: {
      main: "#2e7d32",
    },

    secondary: {
      main: "#66bb6a",
    },

    background: {
      default: "#f4f9f4",
      paper: "#ffffff",
    },
  },
});

export default theme;

Understanding createTheme()

createTheme()

creates a centralized design system for the application.

This allows all Material UI components to automatically use the same:

  • Colors

  • Typography

  • Spacing system

  • Component styling

For example:

<Button color="primary" />

will automatically use:

#2e7d32

from the theme configuration.

Configuring ThemeProvider

To apply the theme globally, we wrap the application with:

<ThemeProvider>

Inside:

src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";

import App from "./App";

import {
  ThemeProvider,
} from "@mui/material/styles";

import CssBaseline from "@mui/material/CssBaseline";

import theme from "./theme";

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <ThemeProvider theme={theme}>
    <CssBaseline />
    <App />
  </ThemeProvider>
);

Understanding CssBaseline

<CssBaseline />

acts as a global CSS reset for Material UI applications.

It helps:

  • Remove inconsistent browser styling

  • Apply cleaner default styles

  • Normalize margins and typography

  • Apply the theme background globally


Step 9: Setting Up Global State Management with Redux Toolkit

As ShelfLife started growing, managing shared application state became important.

For example:

  • Logged-in user information

  • Authentication state

  • Household data

  • Shared inventory information

Passing this data manually through props across multiple components would quickly become difficult to maintain.

To solve this, I decided to use Redux Toolkit.


Installing Redux Toolkit

To set up Redux Toolkit, install:

npm install @reduxjs/toolkit react-redux

Understanding the Packages

@reduxjs/toolkit

provides the modern Redux APIs for creating stores and slices.

react-redux

connects Redux with React components.

I followed the official Redux Toolkit Quick Start documentation:

Redux Toolkit Quick Start Guide


Why I Introduced Redux Toolkit

As the application started interacting with the backend API, multiple components needed access to the same server-side data.

For example:

  • Logged-in user information

  • Household details

  • Shared inventory items

  • Authentication state

Without global state management, this data would need to be passed manually through props across many components, which quickly becomes difficult to maintain.

To solve this, I introduced Redux Toolkit as a centralized global store for managing server-driven application state.

The idea is simple:

Backend API
    ↓
Frontend fetches data
    ↓
Redux Store
    ↓
Any Component Can Access It

This allows data coming from the server to be stored centrally and shared across the entire application efficiently.

Benefits of This Approach

  • Avoids prop drilling

  • Centralized application state

  • Easier data sharing between components


Creating the Global Store

Inside:

src/store/appStore.js

I created the main Redux store.

import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./userSlice";

const appStore = configureStore({
  reducer: {
    user: userReducer,
  },
});

export default appStore;

The store acts as a centralized container for the entire application state.

Current store structure:

store
 └── user

Creating the User Slice

Redux Toolkit organizes state into smaller isolated pieces called slices.

Inside:

src/store/userSlice.js

I created the user slice.

import { createSlice } from "@reduxjs/toolkit";

const userSlice = createSlice({
  name: "user",
  initialState: null,

  reducers: {
    addUser: (state, action) => {
      return action.payload;
    },

    removeUser: (state, action) => {
      return null;
    },
  },
});

export const { addUser, removeUser } = userSlice.actions;

export default userSlice.reducer;

Providing the Redux Store to the Application

After creating the store, the next step was making it accessible throughout the entire application.

Inside:

src/App.jsx

I wrapped the application using Redux Provider.

import { Provider } from "react-redux";
import appStore from "@/store/appStore";

<Provider store={appStore}>
  <BrowserRouter>
    <App />
  </BrowserRouter>
</Provider>

Understanding Provider

<Provider>

connects React with the Redux store.

Without it, components cannot access global state.

Once wrapped, any component inside the application can:

  • Read state using useSelector

  • Update state using useDispatch


Step 10: Setting Up Axios

Now let’s connect the frontend with the backend

Axios helps simplify:

  • API requests

  • Request configuration

  • Error handling

  • Interceptors


Installing Axios

Install Axios:

npm install axios

Creating Environment Variables

Instead of hardcoding backend URLs, I used environment variables.

Inside:

.env
VITE_API_URL=http://localhost:5000/api

Since this project uses Vite, environment variables must start with:

VITE_

Otherwise, Vite will not expose them to the frontend application.


Creating the Axios Instance

Inside:

src/api/axios.js
import axios from "axios";

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,

  withCredentials: true,

  timeout: 10000,
});

export default api;

Understanding the Configuration
axios.create()

Creates a reusable Axios instance.

Instead of repeating configuration in every API request, we centralize everything in one place.


baseURL

Sets the backend base URL globally.

So this:

api.post("/auth/login")

automatically becomes:

http://localhost:5000/api/auth/login

withCredentials: true

Allows cookies to be sent automatically with requests.

This becomes important for refresh token authentication because refresh tokens are stored securely inside HTTP-only cookies.


timeout: 10000

Axios will wait a maximum of:

10000 milliseconds = 10 seconds

before aborting the request.

This prevents requests from hanging forever if the server does not respond.


Setting Up Token Utilities

After login, the backend returns an access token.

To manage tokens cleanly, I created utility functions.

Inside:

src/utils/token.js
export const getAccessToken = () =>
  localStorage.getItem("accessToken");

export const setAccessToken = (token) =>
  localStorage.setItem(
    "accessToken",
    token
  );

export const clearTokens = () => {
  localStorage.removeItem(
    "accessToken"
  );
};

Why Create Token Utilities?

Instead of directly using:

localStorage.getItem()

everywhere, utility functions help:

  • Centralize token logic

  • Improve readability

  • Reduce duplication


Setting Up Axios Interceptors

Authentication systems usually require automatic token handling.

To solve this, I used Axios interceptors.

Inside:

src/api/axiosInterceptor.js
import { refreshToken } from "./auth.api";
import api from "./axios";
import { getAccessToken, setAccessToken, clearTokens } from "@/utils/token";

// REQUEST INTERCEPTOR
api.interceptors.request.use((config) => {
  const token = getAccessToken();

  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

// RESPONSE INTERCEPTOR
api.interceptors.response.use(
  (response) => response,

  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const res = await refreshToken();
        console.log(res);
        const newAccessToken = res.data.data.accessToken;

        setAccessToken(newAccessToken);

        api.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;

        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;

        return api(originalRequest);
      } catch (error) {
        clearTokens();

        return Promise.reject(error);
      }
    }

    return Promise.reject(error);
  },
);

Importing the Axios Interceptor

Axios interceptors only work after the interceptor file is imported.

So we need to import it once inside the main application entry file.

Inside:

src/main.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

import "./api/axiosInterceptor";

ReactDOM.createRoot(
  document.getElementById("root")
).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Why Is This Import Important?

The interceptor file does not export a component.

Its purpose is to execute this code immediately:

api.interceptors.request.use()

and

api.interceptors.response.use()

As soon as the file is imported, Axios registers both interceptors globally.

Without importing this file, the interceptors will never run.

That means:

  • Tokens will not attach automatically

  • Refresh token logic will not work

  • Expired access tokens will not refresh automatically


Step 11: Building the Shared Navigation Layout

Most pages inside ShelfLife share a common structure:

  • Navbar

  • Dynamic Page Content

  • Footer

So instead of repeating them on every page, I created reusable layout components.

Creating the Navbar

Inside:

src/components/main/Navbar.jsx
import * as React from "react";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuIcon from "@mui/icons-material/Menu";
import Container from "@mui/material/Container";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import Tooltip from "@mui/material/Tooltip";
import MenuItem from "@mui/material/MenuItem";
import AdbIcon from "@mui/icons-material/Adb";
import { useDispatch, useSelector } from "react-redux";
import { logout } from "./api";
import { clearTokens } from "@/utils/token";
import { useNavigate } from "react-router-dom";
import { removeUser } from "@/store/userSlice";
import { toast } from "react-toastify";

const pages = ["Products", "Pricing", "Blog"];
const settings = ["Profile", "Logout"];

const Navbar = ({ setOpenAuthDialog }) => {
  const user = useSelector((store) => store.user);
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const [anchorElNav, setAnchorElNav] = React.useState(null);
  const [anchorElUser, setAnchorElUser] = React.useState(null);

  const handleOpenNavMenu = (event) => {
    setAnchorElNav(event.currentTarget);
  };
  const handleOpenUserMenu = (event) => {
    setAnchorElUser(event.currentTarget);
  };

  const handleCloseNavMenu = () => {
    setAnchorElNav(null);
  };

  const handleCloseUserMenu = () => {
    setAnchorElUser(null);
  };

  const handleSettingsClick = async (setting) => {
    handleCloseUserMenu();

    if (setting === "Logout") {
      try {
        await logout(); // API call

        clearTokens();

        // remove redux user also
        dispatch(removeUser());

        toast.success("Logout Successful");
        navigate("/");
      } catch (error) {
        console.log(error);
      }
    }
  };

  return (
    <AppBar position="static">
      <Container maxWidth="xl">
        <Toolbar
          disableGutters
          sx={{
            minHeight: "56px !important",
          }}
        >
          <Typography
            variant="h6"
            noWrap
            sx={{
              mr: 2,
              display: { xs: "none", md: "flex" },
              fontFamily: "monospace",
              fontWeight: 700,
              letterSpacing: ".3rem",
              color: "inherit",
              textDecoration: "none",
            }}
          >
            ShelfLife
          </Typography>

          <Typography
            variant="h5"
            noWrap
            component="a"
            href="#app-bar-with-responsive-menu"
            sx={{
              mr: 2,
              display: { xs: "flex", md: "none" },
              flexGrow: 1,
              fontFamily: "monospace",
              fontWeight: 700,
              letterSpacing: ".3rem",
              color: "inherit",
              textDecoration: "none",
            }}
          >
            ShelfLife
          </Typography>
          <Box
            sx={{ flexGrow: 1, display: "flex", justifyContent: "flex-end" }}
          >
            {!user ? (
              <Button
                variant="contained"
                onClick={() => setOpenAuthDialog(true)}
              >
                Login
              </Button>
            ) : (
              <>
                <Tooltip title="Open settings">
                  <IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
                    <Avatar
                      alt="Remy Sharp"
                      src="/static/images/avatar/2.jpg"
                    />
                  </IconButton>
                </Tooltip>
                <Menu
                  sx={{ mt: "45px" }}
                  id="menu-appbar"
                  anchorEl={anchorElUser}
                  anchorOrigin={{
                    vertical: "top",
                    horizontal: "right",
                  }}
                  keepMounted
                  transformOrigin={{
                    vertical: "top",
                    horizontal: "right",
                  }}
                  open={Boolean(anchorElUser)}
                  onClose={handleCloseUserMenu}
                >
                  {settings.map((setting) => (
                    <MenuItem
                      key={setting}
                      onClick={() => handleSettingsClick(setting)}
                    >
                      <Typography sx={{ textAlign: "center" }}>
                        {setting}
                      </Typography>
                    </MenuItem>
                  ))}
                </Menu>
              </>
            )}
          </Box>
        </Toolbar>
      </Container>
    </AppBar>
  );
};

export default Navbar;

I created the application navbar.

The navbar has two authentication states:

User Not Logged In
        ↓
Show Login Button

User Logged In
        ↓
Show Profile Menu

This behavior is controlled using Redux global state.

const user = useSelector((store) => store.user);

Understanding useSelector()

useSelector()

allows React components to access Redux store data.

If no user exists:

<Button onClick={() => setOpenAuthDialog(true)}>
  Login
</Button>

the Login button appears.

Otherwise:

<Avatar />

shows the authenticated user profile menu.

This creates a dynamic authentication-aware navigation system.

Handling Logout

Inside the profile menu, I added logout functionality.

dispatch(removeUser());

removes the authenticated user from Redux state.

At the same time:

clearTokens();

removes stored access tokens from localStorage.

This instantly updates the entire UI because Redux state changes automatically trigger component re-renders.


Creating the Footer

Inside:

src/components/main/Footer.jsx

I created a shared footer component.

import { Box, Container, Typography } from "@mui/material";

const Footer = () => {
  return (
    <Box
      component="footer"
      sx={{
        mt: "auto",
        py: 2,
        textAlign: "center",
        bgcolor: "primary.main",
        color: "white",
      }}
    >
      <Container maxWidth="xl">
        <Typography variant="body2">
          © 2026 ShelfLife. All rights reserved.
        </Typography>
      </Container>
    </Box>
  );
};

export default Footer;

Step12: Connecting Navbar with the Login Dialog

Before building the Login component, I updated the shared layout component so the Navbar could control the authentication dialog.

Inside:

src/components/main/Body.jsx

I added dialog state management.

const [openAuthDialog, setOpenAuthDialog] =
  useState(false);

This state controls whether the authentication modal is open or closed.


Passing State to Navbar

<Navbar
  setOpenAuthDialog={
    setOpenAuthDialog
  }
/>

The setOpenAuthDialog function is passed to the Navbar component as a prop.

This allows the Navbar to open the Login dialog whenever the user clicks the Login button.

Example inside Navbar:

<Button
  variant="contained"
  onClick={() =>
    setOpenAuthDialog(true)
  }
>
  Login
</Button>

When clicked:

setOpenAuthDialog(true);

opens the authentication modal instantly.


Sharing Dialog State with Nested Routes

<Outlet
  context={{
    openAuthDialog,
    setOpenAuthDialog,
  }}
/>

Using React Router's Outlet context, nested pages can also access:

  • openAuthDialog

  • setOpenAuthDialog

This makes the authentication modal globally accessible across shared layout routes.


Authentication Dialog Flow

User Clicks Login
        ↓
Navbar Updates State
        ↓
openAuthDialog = true
        ↓
Login Component Opens

This creates a centralized modal authentication system controlled from the shared layout.


Step 13: Building the Signup Component

Inside:

src/components/auth/Signup.jsx

we create the signup form for new users.

This component handles:

  • User registration form

  • Form validation

  • API integration

  • Error

Creating the Signup Component

Inside

src/components/auth/Signup.jsx
import { useForm } from "react-hook-form";

import CloseIcon from "@mui/icons-material/Close";

import {
  Button,
  Dialog,
  DialogContent,
  DialogTitle,
  IconButton,
  TextField,
  Typography,
  Box,
} from "@mui/material";

import { toast } from "react-toastify";

import { signupUser } from "./api";

const Signup = ({
  open,
  handleClose,
  openLogin,
}) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  const onSubmit = async (data) => {
    try {
      const res =
        await signupUser(data);

      if (res.success) {
        toast.success(
          "Signup Successful"
        );

        handleClose();

        openLogin();
      }
    } catch (err) {
      toast.error("Signup Failed");

      console.error(err);
    }
  };

  return (
    <Dialog
      open={open}
      onClose={handleClose}
      fullWidth
      maxWidth="xs"
      disableScrollLock
    >
      <DialogTitle
        sx={{
          display: "flex",
          justifyContent:
            "space-between",
          alignItems: "center",
        }}
      >
        Create Account

        <IconButton
          onClick={handleClose}
        >
          <CloseIcon />
        </IconButton>
      </DialogTitle>

      <DialogContent>
        <Box
          component="form"
          onSubmit={handleSubmit(
            onSubmit
          )}
        >
          <TextField
            margin="dense"
            label="First Name"
            fullWidth
            {...register(
              "firstName",
              {
                required:
                  "First name is required",
              }
            )}
            error={
              !!errors.firstName
            }
            helperText={
              errors.firstName
                ?.message
            }
          />

          <TextField
            margin="dense"
            label="Last Name"
            fullWidth
            {...register(
              "lastName",
              {
                required:
                  "Last name is required",
              }
            )}
            error={
              !!errors.lastName
            }
            helperText={
              errors.lastName
                ?.message
            }
          />

          <TextField
            margin="dense"
            label="Username"
            fullWidth
            {...register(
              "username",
              {
                required:
                  "Username is required",
              }
            )}
            error={
              !!errors.username
            }
            helperText={
              errors.username
                ?.message
            }
          />

          <TextField
            margin="dense"
            label="Email"
            type="email"
            fullWidth
            {...register("email", {
              required:
                "Email is required",
            })}
            error={!!errors.email}
            helperText={
              errors.email?.message
            }
          />

          <TextField
            margin="dense"
            label="Phone Number"
            type="tel"
            fullWidth
            {...register(
              "phoneNumber",
              {
                required:
                  "Phone number is required",
              }
            )}
            error={
              !!errors.phoneNumber
            }
            helperText={
              errors.phoneNumber
                ?.message
            }
          />

          <TextField
            margin="dense"
            label="Password"
            type="password"
            fullWidth
            {...register(
              "password",
              {
                required:
                  "Password is required",

                minLength: {
                  value: 6,
                  message:
                    "Password must be at least 6 characters",
                },
              }
            )}
            error={
              !!errors.password
            }
            helperText={
              errors.password
                ?.message
            }
          />

          <Typography
            variant="body2"
            sx={{
              mt: 2,
              cursor: "pointer",
              color: "primary.main",
              textAlign: "center",
            }}
            onClick={openLogin}
          >
            Already have an account?
            Login
          </Typography>

          <Box
            sx={{
              mt: 3,
              display: "flex",
              justifyContent:
                "center",
            }}
          >
            <Button
              type="submit"
              variant="contained"
            >
              Signup
            </Button>
          </Box>
        </Box>
      </DialogContent>
    </Dialog>
  );
};

export default Signup;

Creating the Signup API

Inside:

src/components/auth/api.js
import api from "@/api/axios.js";

// SIGNUP USER
export const signupUser =
  async (data) => {
    const res = await api.post(
      "/auth/register",
      data
    );

    return res.data;
  };

After successful registration:

openLogin();

automatically opens the login dialog.

This creates a smooth connected authentication experience:

Signup
   ↓
Account Created
   ↓
Open Login Dialog
   ↓
Login Dialog

Instead of forcing users to manually reopen the login modal, the application guides them directly into Login component.


Step 14: Building the Login Component

Inside:

src/components/auth/Login.jsx

we create the login form.

import { useForm } from "react-hook-form";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";

import {
  Button,
  Dialog,
  DialogContent,
  DialogTitle,
  IconButton,
  TextField,
  Typography,
  Box,
} from "@mui/material";
import { toast } from "react-toastify";

import CloseIcon from "@mui/icons-material/Close";
import { loginUser } from "./api";
import { addUser } from "@/store/userSlice";
import { setAccessToken } from "@/utils/token";

const Login = ({ open, handleClose, openSignup }) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  const dispatch = useDispatch();
  const navigate = useNavigate();

  const onSubmit = async (data) => {
    try {
      const res = await loginUser(data);
      dispatch(addUser(res.data.user));
      setAccessToken(res.data.accessToken);
      toast.success("Login Successful");
      handleClose();
    } catch (err) {
      toast.error("Invalid Credentials");
      console.error(err);
    }
  };

  return (
    <Dialog
      open={open}
      onClose={handleClose}
      fullWidth
      maxWidth="sm"
      disableScrollLock
    >
      <DialogTitle
        sx={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        Login
        <IconButton onClick={handleClose}>
          <CloseIcon />
        </IconButton>
      </DialogTitle>

      <DialogContent>
        <Box component="form" onSubmit={handleSubmit(onSubmit)}>
          <TextField
            margin="dense"
            label="Email"
            type="email"
            fullWidth
            variant="outlined"
            {...register("email", {
              required: "Email is required",
            })}
            error={!!errors.email}
            helperText={errors.email?.message}
          />

          <TextField
            margin="dense"
            label="Password"
            type="password"
            fullWidth
            variant="outlined"
            {...register("password", {
              required: "Password is required",
              minLength: {
                value: 6,
                message: "Password must be at least 6 characters",
              },
            })}
            error={!!errors.password}
            helperText={errors.password?.message}
          />

          <Typography
            variant="body2"
            sx={{
              mt: 2,
              cursor: "pointer",
              color: "primary.main",
              textAlign: "center",
            }}
            onClick={openSignup}
          >
            Create new account
          </Typography>

          <Box
            sx={{
              mt: 3,
              display: "flex",
              justifyContent: "center",
            }}
          >
            <Button type="submit" variant="contained">
              Login
            </Button>
          </Box>
        </Box>
      </DialogContent>
    </Dialog>
  );
};

export default Login;

Creating the Login API

Inside:

src/components/auth/api.js
import api from "@/api/axios.js";

// LOGIN
export const loginUser = async (data) => {
  const res = await api.post("/auth/login", data);
  return res.data;
};

After successful login :

Instead of navigating users to a separate login page, I decided to use modal-based authentication.

Why?

Because it creates a smoother user experience. Users can authenticate without leaving their current page.


Understanding React Hook Form

Instead of manually managing every input using:

useState()

I used:

useForm()

from React Hook Form.

Install:

npm install react-hook-form

Inside the component:

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm();

This helps manage:

  • Form state

  • Validation

  • Submission handling

  • Error handling

with minimal re-renders.


Understanding register()

register("email")

connects the input field to React Hook Form.

This allows React Hook Form to automatically track:

  • Input values

  • Validation state

  • Errors

  • Submission data

without needing multiple state variables.


Understanding handleSubmit()

handleSubmit(onSubmit)

handles passing clean form data to the submit function.


Understanding Validation

Validation rules are added directly during field registration.

Example:

register("password", {
  required: "Password is required",

  minLength: {
    value: 6,
    message:
      "Password must be at least 6 characters",
  },
})

This automatically validates the field before submission.

See:


Understanding Error Handling

Material UI integrates nicely with React Hook Form.

Example:

error={Boolean(errors.email)}

helperText={errors.email?.message}

error

error={Boolean(errors.email)}

puts the TextField into an error state if validation fails.

helperText

helperText={errors.email?.message}

displays the actual validation message below the input field.

This creates a much better user experience compared to manual validation handling.

See:


Step15: Rendering Login and Signup Components

Inside:

src/components/joinHousehold/JoinHouseHold.jsx

I rendered both authentication components.

import { useOutletContext } from "react-router-dom";

const JoinHouseHold = () => {
   const { openAuthDialog, setOpenAuthDialog } = useOutletContext();
   const [openSignup, setOpenSignup] = useState(false);

   return (
        <>
            {!openSignup ? (
               <Login
                  open={openAuthDialog}
                  handleClose={() => setOpenAuthDialog(false)}
                  openSignup={() => {
                      setOpenAuthDialog(false);
                      setOpenSignup(true);
                   }}
                 />
              ) : (
                 <Signup
                    open={openSignup}
                    handleClose={() => setOpenSignup(false)}
                    openLogin={() => {
                       setOpenSignup(false);
                       setOpenAuthDialog(true);
                     }}
                   />
                )}
         </>
    )
};

export default JoinHouseHold;

Initially:

openSignup = false

So the Login component is rendered.

When users click:

Create new account

inside the Login dialog:

setOpenAuthDialog(false);

setOpenSignup(true);

closes the Login modal and opens the Signup modal.

Similarly, after successful signup:

openLogin();

inside the Signup component:

setOpenSignup(false);

setOpenAuthDialog(true);

closes the Signup dialog and reopens the Login dialog.

This creates a connected modal-based authentication flow:

Login Dialog
      ↓
Create Account
      ↓
Signup Dialog
      ↓
Account Created
      ↓
Login Dialog

Step 16: Persistent Authentication

One major problem in frontend authentication is:

Redux state resets after page refresh

To solve this, ShelfLife restores the user session whenever the application loads.

Inside:

src/components/main/Body.jsx

I added authentication handling logic to manage:

  • Persistent login

  • User fetching

useEffect(() => {
  const token =
    localStorage.getItem(
      "accessToken"
    );

  if (token) {
    fetchUser();
  }
}, []);

The application first checks whether an access token already exists.

If a token is found:

fetchUser();

calls the backend API to retrieve the authenticated user's data.

Fetching the Authenticated User

const fetchUser = async () => {
  try {
    const res = await getMe();

    dispatch(addUser(res.user));
  } catch (error) {
    toast.error(
      error?.response?.data?.message ||
        "Failed to fetch user"
    );
  }
};

Once the backend returns the user data:

dispatch(addUser(res.user));

stores the user globally inside Redux.

This allows every component in the application to access authenticated user information.

Why This Step Is Important

Without this logic:

  • User data disappears after refresh

  • Navbar loses authentication state

  • Protected routes stop working properly

With this setup:

  • Authentication persists across refreshes

  • Redux state gets restored automatically

  • User experience becomes seamless


Step 14: Building the Join Household Flow

After completing authentication, I started building the household onboarding flow.

The main goal was:

Only authenticated users should be able to join or create households

So before opening any household dialogs, I first check whether the user is logged in or not.

Inside:

src/components/joinHousehold/JoinHouseHold.jsx

I created the complete household flow system.

import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { useSelector } from "react-redux";

import { Box, Button, Container, Typography, Paper } from "@mui/material";

import Login from "../auth/Login";
import Signup from "../auth/Signup";

import JoinWithInviteDialog from "./JoinWithInviteDialog";
import CreateHouseholdDialog from "./CreateHouseholdDialog";

const JoinHouseHold = () => {
  const user = useSelector((store) => store.user);

  const { openAuthDialog, setOpenAuthDialog } = useOutletContext();

  const [openSignup, setOpenSignup] = useState(false);

  const [openJoinDialog, setOpenJoinDialog] = useState(false);

  const [openCreateDialog, setOpenCreateDialog] = useState(false);

  const handleJoinHousehold = () => {
    const token = localStorage.getItem("accessToken");

    if (!token || !user) {
      setOpenAuthDialog(true);
      return;
    }

    setOpenJoinDialog(true);
  };

  return (
    <>
      <Container maxWidth="md">
        <Box
          sx={{
            minHeight: "80vh",
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
          }}
        >
          <Paper
            elevation={4}
            sx={{
              p: 5,
              borderRadius: 4,
              textAlign: "center",
              width: "100%",
              maxWidth: "700px",
            }}
          >
            <Typography variant="h3" fontWeight="bold" gutterBottom>
              Building a ShelfLife Household Inventory Tracker
            </Typography>

            <Typography
              variant="body1"
              color="text.secondary"
              sx={{
                mt: 2,
                mb: 4,
              }}
            >
              Manage household items, track inventory, and collaborate with your
              family members easily.
            </Typography>

            <Button
              variant="contained"
              size="large"
              onClick={handleJoinHousehold}
            >
              Join Household
            </Button>
          </Paper>
        </Box>
      </Container>

      {!openSignup ? (
        <Login
          open={openAuthDialog}
          handleClose={() => setOpenAuthDialog(false)}
          openSignup={() => {
            setOpenAuthDialog(false);
            setOpenSignup(true);
          }}
        />
      ) : (
        <Signup
          open={openSignup}
          handleClose={() => setOpenSignup(false)}
          openLogin={() => {
            setOpenSignup(false);
            setOpenAuthDialog(true);
          }}
        />
      )}

      <JoinWithInviteDialog
        open={openJoinDialog}
        handleClose={() => setOpenJoinDialog(false)}
        openCreateDialog={() => {
          setOpenJoinDialog(false);
          setOpenCreateDialog(true);
        }}
      />

      <CreateHouseholdDialog
        open={openCreateDialog}
        handleClose={() => setOpenCreateDialog(false)}
      />
    </>
  );
};

export default JoinHouseHold;

Checking Authentication Before Joining

const handleJoinHousehold = () => {
  const token =
    localStorage.getItem(
      "accessToken"
    );

  if (!token || !user) {
    setOpenAuthDialog(true);

    return;
  }

  setOpenJoinDialog(true);
};

When users click:

Join Household

the application first checks:

  • Does an access token exist?

  • Does authenticated user data exist in Redux?

If authentication is missing:

setOpenAuthDialog(true);

opens the Login dialog.

Otherwise:

setOpenJoinDialog(true);

opens the Join Household dialog.


Understanding the Flow

The complete flow works like this:

User Clicks Join Household
            ↓
Check Authentication
            ↓
User Logged In?
      ↙      ↘
     No      Yes
     ↓        ↓
Open Login  Open Join Dialog
Dialog

This prevents unauthenticated users from accessing household functionality.


Join Household Dialog

Inside:

src/components/joinHousehold/JoinWithInviteDialog.jsx

I created the household joining dialog.

import {
  Box,
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  TextField,
  Typography,
  IconButton,
} from "@mui/material";

import { useForm } from "react-hook-form";
import { joinHousehold } from "./api";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import CloseIcon from "@mui/icons-material/Close";

const JoinWithInviteDialog = ({ open, handleClose, openCreateDialog }) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm({
    defaultValues: {
      inviteCode: "",
    },
  });

  const navigate = useNavigate();

  const onSubmit = async (data) => {
    try {
      const res = await joinHousehold(data);
      navigate("/items");
      reset();
      toast.success("Household join successfully");
      handleClose();
    } catch (err) {
      toast.error("Invalid Invite code");
      console.error(err);
    }
  };

  return (
    <Dialog
      open={open}
      onClose={handleClose}
      fullWidth
      maxWidth="sm"
      disableScrollLock
    >
      <DialogTitle
        sx={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        Join Household
        <IconButton onClick={handleClose}>
          <CloseIcon />
        </IconButton>
      </DialogTitle>

      <DialogContent>
        <form onSubmit={handleSubmit(onSubmit)}>
          <TextField
            fullWidth
            label="Invite Code"
            margin="normal"
            error={!!errors.inviteCode}
            helperText={errors.inviteCode?.message}
            inputProps={{
              maxLength: 6,
            }}
            {...register("inviteCode", {
              required: "Invite code is required",

              pattern: {
                value: /^[0-9]{6}$/,
                message: "Invite code must be 6 digits",
              },

              onChange: (e) => {
                e.target.value = e.target.value.replace(/\D/g, "").slice(0, 6);
              },
            })}
          />

          <Typography
            sx={{
              mt: 3,
              textAlign: "center",
            }}
          >
            Want to create your own household?
          </Typography>

          <Box
            sx={{
              display: "flex",
              justifyContent: "center",
              mt: 2,
            }}
          >
            <Button variant="outlined" onClick={openCreateDialog}>
              Create New Household
            </Button>
          </Box>

          <DialogActions>
            <Button type="submit" variant="contained">
              Join
            </Button>
          </DialogActions>
        </form>
      </DialogContent>
    </Dialog>
  );
};

export default JoinWithInviteDialog;

Users can:

  • Enter an invite code

  • Join an existing household

  • Open the create household dialog


JoinHouseHold API

Inside:

src/components/joinHouseHold/api.js
import api from "@/api/axios.js";

export const joinHousehold = async (data) => {
  const res = await api.post("/households/join", data);
  return res.data;
};

Joining a Household

const onSubmit = async (data) => {
  try {
    const res =
      await joinHousehold(data);

    navigate("/items");

    reset();

    toast.success(
      "Household join successfully"
    );

    handleClose();
  } catch (err) {
    toast.error(
      "Invalid Invite code"
    );

    console.error(err);
  }
};

After successfully joining:

  • User is redirected to the items page

  • Form resets

  • Success message appears

  • Dialog closes automatically


Creating a New Household Component

Inside the Join dialog, users can also create their own household.

<Button
  variant="outlined"
  onClick={openCreateDialog}
>
  Create New Household
</Button>

When clicked:

Join Dialog Closes
        ↓
Create Household Dialog Opens

This creates a connected onboarding flow.


Create Household Dialog

Inside:

src/components/joinHousehold/CreateHouseholdDialog.jsx

I created the household creation form.

import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  TextField,
  IconButton,
} from "@mui/material";

import { useForm } from "react-hook-form";
import { toast } from "react-toastify";
import { createHousehold } from "./api";
import { useNavigate } from "react-router-dom";
import CloseIcon from "@mui/icons-material/Close";

const CreateHouseholdDialog = ({ open, handleClose }) => {
  const navigate = useNavigate();
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm({
    defaultValues: {
      householdName: "",
      inviteCode: "",
    },
  });

  const onSubmit = async (data) => {
    try {
      const res = await createHousehold(data);
      navigate("/items");
      reset();
      toast.success("Houshold created successfully");
      handleClose();
    } catch (err) {
      toast.error("Something went wrong");
      console.error(err);
    }
    console.log(data);
  };

  return (
    <Dialog
      open={open}
      onClose={handleClose}
      fullWidth
      maxWidth="sm"
      disableScrollLock
    >
      <DialogTitle
        sx={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        Create Household
        <IconButton onClick={handleClose}>
          <CloseIcon />
        </IconButton>
      </DialogTitle>

      <DialogContent>
        <form onSubmit={handleSubmit(onSubmit)}>
          <TextField
            fullWidth
            label="Household Name"
            margin="normal"
            error={!!errors.householdName}
            helperText={errors.householdName?.message}
            {...register("householdName", {
              required: "Household name is required",
            })}
          />

          <TextField
            fullWidth
            label="Invite Code"
            margin="normal"
            error={!!errors.inviteCode}
            helperText={errors.inviteCode?.message}
            inputProps={{
              maxLength: 6,
            }}
            {...register("inviteCode", {
              required: "Invite code is required",

              pattern: {
                value: /^[0-9]{6}$/,
                message: "Invite code must be 6 digits",
              },

              onChange: (e) => {
                e.target.value = e.target.value.replace(/\D/g, "").slice(0, 6);
              },
            })}
          />

          <DialogActions>
            <Button type="submit" variant="contained">
              Create
            </Button>
          </DialogActions>
        </form>
      </DialogContent>
    </Dialog>
  );
};

export default CreateHouseholdDialog;

The form includes:

  • Household name

  • 6-digit invite code


Creating the Create HouseHold API

Inside:

src/components/joinHouseHold/api.js
import api from "@/api/axios.js";

export const createHousehold = async (data) => {
  const res = await api.post("/households", data);
  return res.data;
};

Creating a Household

const onSubmit = async (data) => {
  try {
    const res =
      await createHousehold(data);

    navigate("/items");

    reset();

    toast.success(
      "Houshold created successfully"
    );

    handleClose();
  } catch (err) {
    toast.error(
      "Something went wrong"
    );

    console.error(err);
  }
};

After successful creation:

  • Household gets created

  • User navigates to inventory items

  • Form resets

  • Success toast appears

  • Dialog closes automatically


Step 17: Implementing Frontend Route Protection (Protected Routes)

I used this article by Robin Wieruch as inspiration for understanding and implementing protected routes in React Router:

React Router Private Routes Guide

After completing the authentication and household onboarding flow, the next important step was protecting application routes on the frontend.

At this point, ShelfLife already had:

  • Authentication system

  • Persistent login

  • Household creation/join flow

But there was still one major issue:

Users could manually access routes using URLs even if they were not authorized.

Example:

/items

A user could directly type this URL in the browser.

To solve this, I implemented:

  • Protected Routes

  • Public Only Routes

using React Router.


Why Route Protection Is Important

ShelfLife has two different application states.

Public Area

Accessible to everyone:

/

This page contains:

  • Login

  • Signup

  • Join household onboarding


Protected Area

Accessible only when:

  • User is authenticated

  • User belongs to a household

Example:

/items

This contains the actual inventory dashboard.

So the rule becomes:

User logged in + household joined
                ↓
Allow access to dashboard "/items"

Otherwise
                ↓
Redirect back to "/"

Understanding the Routing Architecture

The routing flow now looks like:

BrowserRouter
      ↓
Routes
      ↓
Body Layout
      ↓
Protected/Public Route Guards
      ↓
Actual Pages

Creating the Route Configuration

Inside:

src/routes/index.js

I added route metadata.

import { lazy } from "react";

export const routes = [
  {
    path: "",

    component: lazy(() =>
      import("@/components/joinHouseHold/JoinHouseHold")
    ),

    isProtected: false,
  },

  {
    path: "/items",

    component: lazy(() =>
      import("@/components/dashboard/items")
    ),

    isProtected: true,
  },
];

Creating the Protected Route Component

Inside:

src/routes/ProtectedRoute.jsx

I created the route guard.

import {
  Navigate,
  Outlet,
  useOutletContext,
} from "react-router-dom";

import { useSelector } from "react-redux";

const ProtectedRoute = ({ children }) => {
  const user = useSelector(
    (store) => store.user
  );

  const context = useOutletContext();

  // USER NOT LOGGED IN
  if (!user) {
    return <Navigate to="/" replace />;
  }

  // USER HAS NOT JOINED HOUSEHOLD
  if (!user.householdId) {
    return <Navigate to="/" replace />;
  }

  return children
    ? children
    : <Outlet context={context} />;
};

export default ProtectedRoute;

Creating Public Only Routes

Now we also need the opposite behavior.

If the user already joined a household:

Do NOT allow access to "/"

Instead:

Redirect directly to "/items"

Inside:

src/routes/PublicOnlyRoute.jsx
import {
  Navigate,
  Outlet,
  useOutletContext,
} from "react-router-dom";

import { useSelector } from "react-redux";

const PublicOnlyRoute = ({
  children,
}) => {
  const user = useSelector(
    (store) => store.user
  );

  const context = useOutletContext();

  if (user?.householdId) {
    return (
      <Navigate
        to="/items"
        replace
      />
    );
  }

  return children
    ? children
    : <Outlet context={context} />;
};

export default PublicOnlyRoute;

Understanding PublicOnlyRoute

This component prevents authenticated household users from returning to onboarding pages.

Meaning:

Already onboarded user
         ↓
Skip landing page
         ↓
Go directly to dashboard

This improves user experience significantl


Final Route Flow

The application behavior now becomes:

VISIT "/"
     ↓
User has household?
   ↙       ↘
 No         Yes
 ↓           ↓
Show       Redirect
Landing    to /items
Page

And:

VISIT "/items"
       ↓
User authenticated?
   ↙          ↘
 No            Yes
 ↓              ↓
Redirect      Has Household?
to "/"        ↙         ↘
             No         Yes
             ↓           ↓
         Redirect     Allow Access
           to "/"

Configuring Protected Routes in App.jsx

Inside:

src/App.jsx

I dynamically wrapped routes using route metadata.

<Routes>
  <Route path="/" element={<Body />}>
    {routes.map((route) => {
      const Component = route.component;

      // PROTECTED ROUTES
      if (route.isProtected) {
        return (
          <Route
            key={route.path}
            element={<ProtectedRoute />}
          >
            <Route
              path={route.path}
              element={<Component />}
            />
          </Route>
        );
      }

      // PUBLIC ROUTES
      return (
        <Route
          key={route.path}
          element={<PublicOnlyRoute />}
        >
          <Route
            path={route.path}
            element={<Component />}
          />
        </Route>
      );
    })}
  </Route>
</Routes>

Understanding Nested Route Protection

This structure:

<Route element={<ProtectedRoute />}>
  <Route
    path="/items"
    element={<Items />}
  />
</Route>

means:

Before rendering /items
       ↓
Run ProtectedRoute first
       ↓
If allowed → render Items
Else → redirect

This is called:

Layout Route Protection

in React Router.


Why This Architecture Is Powerful

This approach scales extremely well.

In future, adding new protected routes becomes very easy.

Example:

{
   path: "/analytics",
   component: Analytics,
   isProtected: true
}

No additional protection logic needed.

The route automatically becomes protected.


Final Authentication Architecture

The complete frontend authentication system now looks like:

User Login
     ↓
Redux Store Updated
     ↓
Protected Routes Read Redux State
     ↓
Allow / Block Route Access
     ↓
React Router Redirects User

Step 18: Creating a Reusable Dashboard Layout with Nested Routes

After implementing protected routes, the next improvement was organizing all authenticated pages inside a reusable dashboard layout.

At this point, ShelfLife had multiple authenticated pages like:

  • /items

  • /members

All these pages needed:

  • A common sidebar

  • Shared dashboard structure

  • Consistent spacing/layout

  • Route navigation

  • Protected access

Instead of repeating sidebar code on every page, I created a reusable DashboardLayout.


Creating DashboardLayout

I created:

components/layout/DashboardLayout.jsx
import {
  Outlet,
  useLocation,
  useNavigate,
  useOutletContext,
} from "react-router-dom";

import {
  Box,
  Drawer,
  List,
  ListItemButton,
  ListItemIcon,
  ListItemText,
  Toolbar,
} from "@mui/material";

import Inventory2Icon from "@mui/icons-material/Inventory2";
import GroupIcon from "@mui/icons-material/Group";

const drawerWidth = 240;

const menuItems = [
  {
    label: "Items",
    path: "/items",
    icon: <Inventory2Icon />,
  },
  {
    label: "Members",
    path: "/members",
    icon: <GroupIcon />,
  },
];

const DashboardLayout = () => {
  const navigate = useNavigate();

  const location = useLocation();

  const context = useOutletContext();

  return (
    <Box sx={{ display: "flex", flex: 1 }}>
      {/* SIDEBAR */}
      <Drawer
        variant="permanent"
        sx={{
          width: drawerWidth,
          flexShrink: 0,

          [`& .MuiDrawer-paper`]: {
            width: drawerWidth,
            boxSizing: "border-box",
            position: "relative",
            height: "calc(100vh - 64px)",
          },
        }}
      >
        <Toolbar />

        <Box sx={{ overflow: "auto", mt: 2 }}>
          <List>
            {menuItems.map((item) => {
              const isActive = location.pathname === item.path;

              return (
                <ListItemButton
                  key={item.path}
                  selected={isActive}
                  onClick={() => navigate(item.path)}
                  sx={{
                    mx: 1,
                    borderRadius: 2,
                    mb: 1,
                  }}
                >
                  <ListItemIcon>{item.icon}</ListItemIcon>

                  <ListItemText primary={item.label} />
                </ListItemButton>
              );
            })}
          </List>
        </Box>
      </Drawer>

      {/* PAGE CONTENT */}
      <Box
        component="main"
        sx={{
          flexGrow: 1,
          p: 3,
          bgcolor: "#f5f5f5",

          width: `calc(100% - ${drawerWidth}px)`,

          overflowX: "auto",

          minWidth: 0,

          height: "calc(100vh - 64px)",

          overflowY: "auto",
        }}
      >
        <Outlet context={context} />
      </Box>
    </Box>
  );
};

export default DashboardLayout;

Why use Outlet?

React Router’s <Outlet /> renders child routes inside the layout.

This allows all authenticated pages to share:

  • Sidebar

  • Navigation

while only changing the page content.

Example:

/items
/members

Both pages now render inside the same dashboard layout.


Updating App.jsx with Nested Protected Routes

Next, I wrapped all protected routes inside:

<DashboardLayout />

using nested routing.

<Route
  element={
    <ProtectedRoute>
      <DashboardLayout />
    </ProtectedRoute>
  }
>
  {routes
    .filter((route) => route.isProtected)
    .map((route) => {
      const Component = route.component;

      return (
        <Route
          key={route.path}
          path={route.path}
          element={<Component />}
        />
      );
    })}
</Route>

Benefits of This Architecture

This structure gives several advantages:

  • Reusable Layout

All dashboard pages automatically share the same layout.

  • Cleaner Routing

Protected routes are grouped together in one place.

  • Better Scalability

Adding new authenticated pages becomes very easy.

  • Centralized Navigation

Sidebar navigation is managed from one component.

  • Better User Experience

Users get a consistent dashboard UI across all pages.


Step 19: Building the Inventory Management Table using Material React Table

After setting up the reusable dashboard layout, the next major feature was building the inventory management system.

The requirements for the inventory table were:

Inventory Table Requirements

Columns:

  • Name

  • Category

  • Quantity

  • Expiry Date

  • Status

  • Actions

Additional requirements:

  • Add Item button

  • Edit item

  • Delete item

  • Search

  • Sorting

  • Pagination

  • Column filtering

  • Inline row editing

  • Future barcode scanning support

  • Responsive dashboard integration

There was also a future flow planned for adding products:

Add Item
    ↓
Open Scanner
    ↓
Scan Barcode
    ↓
Auto-fill Product Info
    ↓
User completes remaining fields
    ↓
Save Item

Because of all these requirements, I needed a powerful and scalable table solution.


Choosing Material React Table

After researching different table libraries, I decided to use:

material-react-table

The reason for choosing Material React Table was because it already provides many production-level features out of the box:

  • Inline editing

  • Row actions

  • Pagination

  • Sorting

  • Filtering

  • Global search

  • Material UI integration

  • Flexible customization

  • Better developer experience

This made it a perfect fit for the ShelfLife inventory system.


Creating the Items Page

I created:

items/Items.jsx
import { useEffect, useState } from "react";

import { Box, Button, Typography } from "@mui/material";

import AddIcon from "@mui/icons-material/Add";

import InventoryTable from "./InventoryTable.jsx";
import { toast } from "react-toastify";
import { getItems } from "./api.js";

const Items = () => {
  const [open, setOpen] = useState(false);

  const [editingItem, setEditingItem] = useState(null);

  const [items, setItems] = useState([]);

  const fetchItems = async () => {
    try {
      const res = await getItems();

      setItems(res.data.items);
    } catch (error) {
      toast.error(error.response?.data?.message || "Failed to fetch items");
    }
  };

  useEffect(() => {
    fetchItems();
  }, []);

  return (
    <Box>
      <Box
        sx={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          mb: 3,
        }}
      >
        <Typography variant="h5" fontWeight={700}>
          Inventory Items
        </Typography>

        <Button
          variant="contained"
          startIcon={<AddIcon />}
          onClick={() => {
            setEditingItem(null);

            setOpen(true);
          }}
        >
          Add Item
        </Button>
      </Box>

      <InventoryTable
        items={items}
        fetchItems={fetchItems}
      />
    </Box>
  );
};

export default Items;

Creating the Inventory Table

Next, I created:

items/InventoryTable.jsx

This component handles:

  • Table rendering

  • Inline row editing

  • Validation

  • Status rendering

  • Table actions

  • API updates

  • Sorting/filtering/pagination

import { useMemo, useState } from "react";

import {
  MaterialReactTable,
  useMaterialReactTable,
} from "material-react-table";

import { Box, Chip, IconButton, Tooltip, TextField } from "@mui/material";

import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import { toast } from "react-toastify";
import { updateItem } from "./api";

const getStatus = (expiryDate) => {
  const today = new Date();

  const expiry = new Date(expiryDate);

  const diffDays = Math.ceil((expiry - today) / (1000 * 60 * 60 * 24));

  if (diffDays < 0) {
    return "expired";
  }

  if (diffDays <= 3) {
    return "expiring-soon";
  }

  return "fresh";
};

const InventoryTable = ({ items, fetchItems }) => {
  const [validationErrors, setValidationErrors] = useState({});

  const columns = useMemo(
    () => [
      {
        accessorKey: "name",
        header: "Name",
        muiEditTextFieldProps: {
          required: true,
          error: !!validationErrors?.name,
          helperText: validationErrors?.name,
          onFocus: () =>
            setValidationErrors({
              ...validationErrors,
              name: undefined,
            }),
        },
      },
      {
        accessorKey: "category",
        header: "Category",
        muiEditTextFieldProps: {
          required: true,
          error: !!validationErrors?.category,
          helperText: validationErrors?.category,
          onFocus: () =>
            setValidationErrors({
              ...validationErrors,
              category: undefined,
            }),
        },
      },
      {
        accessorKey: "quantity",
        header: "Quantity",
        muiEditTextFieldProps: {
          type: "number",
          required: true,
          error: !!validationErrors?.quantity,
          helperText: validationErrors?.quantity,
          onFocus: () =>
            setValidationErrors({
              ...validationErrors,
              quantity: undefined,
            }),
        },
      },
      {
        accessorKey: "expiryDate",
        header: "Expiry Date",
        Edit: ({ cell, column, row, table }) => (
          <TextField
            type="date"
            value={cell.getValue()?.split("T")[0] || ""}
            onChange={(e) => (row._valuesCache[column.id] = e.target.value)}
            fullWidth
          />
        ),
        Cell: ({ cell }) => {
          const value = cell.getValue();

          if (!value) return "";

          return new Date(value).toLocaleDateString();
        },
      },
      {
        header: "Status",
        enableEditing: false,
        Cell: ({ row }) => {
          const status = getStatus(row.original.expiryDate);

          return (
            <Chip
              label={status}
              color={
                status === "fresh"
                  ? "success"
                  : status === "expiring-soon"
                    ? "warning"
                    : "error"
              }
            />
          );
        },
      },
    ],
    [validationErrors],
  );

  const handleSaveRow = async ({ values, table, row }) => {
    const errors = validateItem(values);

    if (Object.values(errors).some(Boolean)) {
      setValidationErrors(errors);
      return;
    }

    try {
      setValidationErrors({});

      const payload = {
        name: values.name,
        category: values.category,
        quantity: values.quantity,
        expiryDate: values.expiryDate,
      };

      await updateItem(row.original._id, payload);

      await fetchItems();

      toast.success("Item updated successfully");

      table.setEditingRow(null);
    } catch (error) {
      toast.error(error.response?.data?.message || "Failed to update item");
    }
  };

  const handleDelete = (row) => {
    // if (window.confirm("Are you sure you want to delete this item?")) {
    //   setItems((prev) => prev.filter((item) => item._id !== row.original._id));
    // }
  };

  const table = useMaterialReactTable({
    columns,
    data: items,

    enableEditing: true,

    editDisplayMode: "row",

    getRowId: (row) => row._id,

    onEditingRowSave: handleSaveRow,

    onEditingRowCancel: () => setValidationErrors({}),

    renderRowActions: ({ row, table }) => (
      <Box sx={{ display: "flex", gap: "8px" }}>
        <Tooltip title="Edit">
          <IconButton onClick={() => table.setEditingRow(row)}>
            <EditIcon />
          </IconButton>
        </Tooltip>

        <Tooltip title="Delete">
          <IconButton color="error" onClick={() => handleDelete(row)}>
            <DeleteIcon />
          </IconButton>
        </Tooltip>
      </Box>
    ),

    enableSorting: true,
    enablePagination: true,
    enableColumnFilters: true,
    enableGlobalFilter: true,

    positionGlobalFilter: "left",
  });

  return <MaterialReactTable table={table} />;
};

export default InventoryTable;

const validateRequired = (value) => !!value?.toString().trim();

function validateItem(item) {
  return {
    name: !validateRequired(item.name) ? "Name is required" : "",

    category: !validateRequired(item.category) ? "Category is required" : "",

    quantity: !validateRequired(item.quantity) ? "Quantity is required" : "",
  };
}

Built-in Table Features

Material React Table also made it very easy to enable advanced table features:

enableSorting: true,
enablePagination: true,
enableColumnFilters: true,
enableGlobalFilter: true,

This instantly added:

  • Sorting

  • Pagination

  • Search

  • Filtering

without building custom logic manually.


Fetching Inventory Items from Backend

After creating the inventory table UI, the next step was fetching real inventory data from the backend.

For API communication, I was already using Axios with a centralized Axios instance.


Creating the API Function

I created a reusable API function for fetching inventory items.

api.js

import api from "@/api/axios.js";

// GET ITEMS
export const getItems = async () => {
  const res = await api.get("/items/");

  return res.data;
};

This keeps API logic separate from UI components, which helps maintain cleaner architecture and better scalability.


Fetching Items Inside Items.jsx

Inside the Items component, I created local state for storing inventory items.

const [items, setItems] = useState([]);

Then I created a reusable fetchItems() function.

const fetchItems = async () => {
  try {
    const res = await getItems();

    setItems(res.data.items);
  } catch (error) {
    toast.error(
      error.response?.data?.message ||
        "Failed to fetch items"
    );
  }
};

This function:

  • Calls backend API

  • Fetches inventory items

  • Updates React state

  • Handles API errors gracefully


Loading Data on Component Mount

To fetch inventory data when the page loads, I used useEffect.

useEffect(() => {
  fetchItems();
}, []);

Because the dependency array is empty:

[]

the API runs only once when the component mounts.

This is useful for:

  • Initial dashboard load

  • Inventory page refresh

  • Fetching latest database data


Making Rows Editable

I enabled editing using:

enableEditing: true,
editDisplayMode: "row",

This allowed users to click the edit icon and instantly convert the entire row into editable input fields.


Adding Editable Columns

For editable columns like:

  • Name

  • Category

  • Quantity

  • Expiry Date

I configured:

muiEditTextFieldProps

Example:

{
  accessorKey: "name",
  header: "Name",
  muiEditTextFieldProps: {
    required: true,
    error: !!validationErrors?.name,
    helperText: validationErrors?.name,
  },
}

This gave:

  • Validation support

  • Error messages


Custom Expiry Date Input

For expiry dates, I customized the edit field using a Material UI date input.

Edit: ({ cell, column, row }) => (
  <TextField
    type="date"
    value={cell.getValue()?.split("T")[0] || ""}
    onChange={(e) =>
      (row._valuesCache[column.id] = e.target.value)
    }
    fullWidth
  />
)

This improved date editing significantly compared to plain text editing.


Status Field Was Read-Only

The inventory status:

  • fresh

  • expiring-soon

  • expired

was automatically calculated from the expiry date.

Because of that, users should not edit status manually.

So I disabled editing for the status column.

{
  header: "Status",
  enableEditing: false,
}

The status was displayed using colored Material UI chips.

<Chip
  label={status}
  color={
    status === "fresh"
      ? "success"
      : status === "expiring-soon"
        ? "warning"
        : "error"
  }
/>

This created a cleaner and more user-friendly inventory UI.


Saving Updated Rows

Material React Table provides:

onEditingRowSave

which runs automatically when the user clicks the save button.

I implemented:


  const handleSaveRow = async ({ values, table, row }) => {
    const errors = validateItem(values);

    if (Object.values(errors).some(Boolean)) {
      setValidationErrors(errors);
      return;
    }

    try {
      setValidationErrors({});

      const payload = {
        name: values.name,
        category: values.category,
        quantity: values.quantity,
        expiryDate: values.expiryDate,
      };

      await updateItem(row.original._id, payload);

      await fetchItems();

      toast.success("Item updated successfully");

      table.setEditingRow(null);
    } catch (error) {
      toast.error(error.response?.data?.message || "Failed to update item");
    }
  };

Frontend Validation Before Save

Before sending updates to the backend, I validated the edited fields.

const errors = validateItem(values);

if (Object.values(errors).some(Boolean)) {
  setValidationErrors(errors);
  return;
}

This prevented invalid inventory updates.


Calling Update API

After validation passed, I created the update payload.

const payload = {
  name: values.name,
  category: values.category,
  quantity: values.quantity,
  expiryDate: values.expiryDate,
};

Then I called the backend update API.

await updateItem(row.original._id, payload);

One important thing I learned here:

values only contains editable/accessor fields.

So:

values._id

was undefined.

The correct way was:

row.original._id

because the original row still contains the MongoDB document ID.


Refetching Inventory After Save

After updating an item successfully:

await fetchItems();

I refetched inventory items from the backend instead of manually updating frontend state.

This approach helped maintain:

  • Fresh backend data

  • Better consistency

  • Simpler state management

  • Cleaner architecture


Adding Delete Row Functionality

After implementing inline editing, the next important feature was deleting inventory items directly from the table.

Material React Table made this very easy using:

renderRowActions

I added a delete icon button inside the row actions section.

<Tooltip title="Delete">
  <IconButton
    color="error"
    onClick={() => handleDelete(row)}
  >
    <DeleteIcon />
  </IconButton>
</Tooltip>

When the user clicks the delete icon, the selected row is passed into:

handleDelete(row)

Creating the Delete Handler

I implemented the delete logic inside:

handleDelete
const handleDelete = async (row) => {
  try {
    await deleteItem(row.original._id);

    await fetchItems();

    toast.success("Item deleted successfully");
  } catch (error) {
    toast.error(
      error.response?.data?.message ||
        "Failed to delete item"
    );
  }
};

This function:

  • Gets the MongoDB item ID

  • Calls backend delete API

  • Refetches latest inventory items

  • Shows success/error toast notifications


Creating Delete API Function

Inside:

api.js

I created a reusable API function.

export const deleteItem = async (itemId) => {
  const res = await api.delete(`/items/${itemId}`);

  return res.data;
};

This keeps API logic reusable and separated from UI components.h backend data

  • Better consistency

  • Simpler state management

  • Cleaner architecture


Step 22: Implementing Server-Side Pagination & Search using Material React Table

After building the inventory management table, the next major improvement was implementing:

  • Server-side pagination

  • Server-side global search

This became important because inventory data can grow very large over time.

Fetching all items at once is not scalable.

Instead, the frontend should only request:

  • the current page

  • current page size

  • current search query

from the backend.

This improves:

  • performance

  • scalability

  • API efficiency

  • database querying


Managing Pagination State

Inside Items.jsx, I created pagination state.

const [pagination, setPagination] = useState({
  pageIndex: 0,
  pageSize: 10,
});

Material React Table uses:

  • pageIndex → current page

  • pageSize → rows per page


Tracking Total Database Rows

Since pagination is handled on the backend, the frontend also needs to know:

  • how many total rows exist in the database

This is required so Material React Table can correctly render:

  • total pages

  • pagination controls

const [rowCount, setRowCount] = useState(0);

Adding Global Search State

Next, I added global search state.

const [globalFilter, setGlobalFilter] = useState("");

This state stores the search value entered by the user.


Adding Debounced Search

Without debouncing, every keystroke would trigger an API request.

Example:

a
ap
app
appl
apple

This would cause 5 API calls.

To prevent unnecessary API requests, I implemented debouncing.

const [debouncedGlobalFilter, setDebouncedGlobalFilter] = useState("");
useEffect(() => {
  const timeout = setTimeout(() => {
    setDebouncedGlobalFilter(globalFilter);
  }, 500);

  return () => clearTimeout(timeout);
}, [globalFilter]);

Now the API only runs after the user stops typing for 500ms.

This significantly improves:

  • performance

  • API efficiency

  • user experience


Fetching Paginated Inventory Items

I updated the API request to send:

  • current page

  • page size

  • search query

to the backend.

const fetchItems = async () => {
  try {
    const res = await getItems(
      pagination.pageIndex + 1,
      pagination.pageSize,
      debouncedGlobalFilter,
    );

    setItems(res.data.items);

    setRowCount(res.data.totalItems);
  } catch (error) {
    toast.error(
      error.response?.data?.message ||
      "Failed to fetch items"
    );
  }
};

Refetching Data When Pagination Changes

Then I added a useEffect to automatically refetch inventory items whenever:

  • page changes

  • page size changes

  • search query changes

useEffect(() => {
  fetchItems();
}, [
  pagination.pageIndex,
  pagination.pageSize,
  debouncedGlobalFilter,
]);

This keeps the table synchronized with backend data.


Resetting to First Page During Search

One important UX improvement was resetting pagination when the search query changes.

Without this:

  • user may stay on page 5

  • search results may only contain 1 page

  • table could appear empty

To fix this:

useEffect(() => {
  setPagination((prev) => ({
    ...prev,
    pageIndex: 0,
  }));
}, [debouncedGlobalFilter]);

Now every new search starts from page 1.


Enabling Manual Pagination in Material React Table

Inside InventoryTable.jsx, I enabled manual pagination.

manualPagination: true,

This tells Material React Table:

"Pagination is controlled by the backend."

So MRT stops doing client-side pagination automatically.


Enabling Manual Server-Side Search

I also enabled:

manualFiltering: true,

This disables client-side searching/filtering.

Now the backend becomes responsible for filtering inventory items.

This is important because users expect search to work across the entire database — not just the currently loaded page.


Connecting Pagination State to Material React Table

onPaginationChange: setPagination,

This automatically updates React state whenever:

  • page changes

  • rows per page changes


Connecting Search State to Material React Table

onGlobalFilterChange: setGlobalFilter,

Whenever the user types into the search bar:

  • MRT updates globalFilter

  • debounce logic runs

  • backend API gets called


Passing Controlled Table State

state: {
  pagination,
  globalFilter,
},

This allows React state to fully control Material React Table state.


Providing Total Row Count

rowCount,

This is extremely important for server-side pagination.

Without rowCount, Material React Table cannot determine:

  • total pages

  • pagination range

  • last page


Final Material React Table Configuration

const table = useMaterialReactTable({
  columns,
  data: items,

  manualPagination: true,
  manualFiltering: true,

  rowCount,

  onPaginationChange: setPagination,
  onGlobalFilterChange: setGlobalFilter,

  state: {
    pagination,
    globalFilter,
  },

  enablePagination: true,
  enableGlobalFilter: true,
  enableColumnFilters: true,
  enableSorting: true,
});

Result

After implementing server-side pagination and global search:

the inventory system became:

  • scalable

  • faster

  • production-ready

  • database-driven

Users can now:

  • search across all inventory items

  • navigate large datasets efficiently

  • load data page-by-page

  • reduce frontend memory usage

This created a much better experience for the ShelfLife inventory dashboard.


Updating the Frontend API for Pagination & Search

Previously, the frontend simply fetched all inventory items.

export const getItems = async () => {
  const res = await api.get("/items/");

  return res.data;
};

But now the backend needed additional query parameters:

  • current page

  • rows per page

  • search query

So I updated the API function.

export const getItems = async (
  page,
  limit,
  search,
) => {
  const res = await api.get(
    `/items?page=\({page}&limit=\){limit}&search=${search}`,
  );

  return res.data;
};

Now the frontend dynamically sends:

  • page → current pagination page

  • limit → number of rows per page

  • search → global search value

Example request:

/items?page=1&limit=10&search=milk

This allows the backend to:

  • paginate database results

  • filter inventory items

  • return only required rows

instead of sending the entire database to the frontend.

This approach is significantly more scalable and production-ready for large datasets.