Build a Construction Material Tracker

Build a React CRUD app to track construction materials with localStorage.

Introduction

30 Second Summary

Construction projects live and die by material availability. When supplies run low without warning, work stops and costs spiral.

In this project, you will build a construction material inventory tracker using React. The app features low-stock alerts, search and filtering, and data that persists between browser sessions.

What You'll Build

A React single-page application that tracks construction materials with full add, edit, and delete functionality, browser-based persistence, and visual status indicators.

By the end of this project, you'll have:

  • A working React web app scaffolded with Vite that manages construction material inventory with add, edit, and delete functionality.
  • Browser-based data persistence using localStorage so your materials survive page refreshes.
  • Search, category filtering, and visual low-stock alerts built into a professional-looking dashboard.
  • Secret Mission: Add a Material Usage Log that records when materials are consumed, showing a history of usage with dates and amounts.

Are there any prerequisites?

None. This guide includes full setup instructions for all required tools, including Node.js and your code editor. No prior React experience is needed.

Before We Start

Before We Start...

Set Up Your Development Environment

Before building the construction material tracker, you need a working React development environment on your machine.

In this step, you'll verify Node.js is ready, scaffold a new React project using Vite, and start the development server.

In this step, get ready to:
  • Verify Node.js and npm are installed.
  • Scaffold a React project with Vite.
  • Start the development server and open the project in VS Code.
Verify your Node.js installation

Node.js is the JavaScript runtime that powers both React and Vite. You need version 20.19 or higher to run the latest version of Vite.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing Enter.
  • Check your Node.js version by running this command:
node --version
  • Check your npm version by running this command:
npm --version

You should see version numbers printed for both commands. Your Node.js version should be 20.19 or higher.

Command not found or version too low?

If you see "command not found" or a version below 20.19, download the latest LTS version from the official Node.js website at https://nodejs.org. Run the installer, restart your terminal, then run node --version again to confirm.

help me install Node.js version 20.19 or higher on my computer

Scaffold the React project with Vite

Vite is a modern build tool that creates a ready-to-use React project in seconds. It handles all the configuration so you can focus on writing components.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new React project called material-tracker by running this command:
npm create vite@latest material-tracker -- --template react

What does this command do?

  • npm create vite@latest downloads and runs the latest Vite project scaffolder.
  • material-tracker is the name of the folder it creates for your project.
  • -- --template react tells Vite to use the React JavaScript template, skipping any interactive prompts.

Seeing a prompt to proceed?

If npm asks "Need to install the following packages... Ok to proceed? (y)", type y and press Enter. This only appears the first time npm downloads the create-vite package.

  • Navigate into your new project folder and install its dependencies by running these commands:
cd material-tracker
npm install

What does npm install do?

Your project has a package.json file that lists the libraries it needs (React, ReactDOM, and Vite). Running npm install downloads all of them into a node_modules folder.

You should see a success message with the number of packages installed.

Seeing permission errors or install failures?

Make sure you ran cd material-tracker before npm install. If you see EACCES permission errors on macOS, try prefixing the command with sudo. On Windows, try running PowerShell as Administrator.

help me fix npm install errors in my Vite React project

Start the development server

Vite includes a development server that shows your app in the browser and refreshes automatically when you make changes.

  • Start the development server by running this command:
npm run dev

You should see output in your terminal showing a local URL.

  • Open your browser and go to http://localhost:5173.

You should see the default Vite + React page with a spinning logo and a counter button.

Page not loading?

Check that your terminal still shows the server running. If you see an error, make sure you are inside the material-tracker folder when you run npm run dev. Also confirm the URL matches the one printed in your terminal output.

help me get the Vite dev server running at localhost:5173

  • Open a new terminal tab or window (keep the dev server running in the current one).
  • Navigate back to your project folder and open it in VS Code by running these commands:
cd ~/Desktop/material-tracker
code .

What if code . doesn't work?

On macOS, you may need to install the VS Code shell command first. Open VS Code manually, press Cmd+Shift+P, type Shell Command: Install, and select the option. Then try code . again in your terminal.

VS Code opens with your project files visible in the left sidebar. You should see folders like src, public, and files like package.json and vite.config.js.

Your development environment is ready. Next up, you'll replace the default scaffold with the app shell and build the material entry form.

Build the App Shell and Material Form

Your development environment is ready and the default Vite app is running. Now it's time to build the real application.

Every inventory system needs two core pieces: a way to display data and a way to enter data. In this step, you'll create the app shell (the layout and state management) and the material entry form.

In this step, get ready to:
  • Replace the default scaffold with your app's base styles and layout.
  • Build a material entry form with categories, quantities, and stock thresholds.
  • Wire the form into the app so new materials get added to state.
Clean up the scaffold and set base styles

The default Vite scaffold includes demo styles and a counter component you don't need. You'll replace those with a clean foundation for your material tracker.

  • In VS Code, open src/index.css from the Explorer sidebar.
  • Select all the existing content and replace it with the following base styles:
:root {
  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #213547;
  background-color: #f5f5f5;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  padding: 2rem;
}

What do these styles do?

The :root block sets a clean system font and a light gray background for the whole page.

The universal selector * resets all default margins and padding, giving you full control over spacing. The box-sizing: border-box rule makes sure padding doesn't add to element widths.

  • Save src/index.css.
  • Check your browser at http://localhost:5173. You should see a plain light gray page with the Vite and React logos gone.

Still seeing the old Vite demo?

Make sure you saved the file. Vite's hot module replacement updates instantly on save, so an unsaved file won't trigger a refresh.

help me debug why my index.css changes aren't showing in the browser

Now you'll replace the default App.jsx with your application shell. This file manages all the app's state and renders the page layout.

  • Open src/App.jsx in VS Code.
  • Select all the existing content and replace it with the following app shell:
import { useState, useEffect } from 'react'
import MaterialForm from './components/MaterialForm'
import MaterialTable from './components/MaterialTable'
import './App.css'

const STORAGE_KEY = 'construction-materials'

function App() {
  const [materials, setMaterials] = useState(() => {
    const saved = localStorage.getItem(STORAGE_KEY)
    return saved ? JSON.parse(saved) : []
  })
  const [editingMaterial, setEditingMaterial] = useState(null)
  const [searchTerm, setSearchTerm] = useState('')
  const [filterCategory, setFilterCategory] = useState('All')

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(materials))
  }, [materials])

  const addMaterial = (material) => {
    setMaterials([...materials, { ...material, id: Date.now().toString() }])
  }

  const updateMaterial = (updatedMaterial) => {
    setMaterials(materials.map(m =>
      m.id === updatedMaterial.id ? updatedMaterial : m
    ))
    setEditingMaterial(null)
  }

  const deleteMaterial = (id) => {
    setMaterials(materials.filter(m => m.id !== id))
  }

  const lowStockCount = materials.filter(m => m.quantity <= m.minStock).length

  const filteredMaterials = materials.filter(m => {
    const matchesSearch = m.name.toLowerCase().includes(searchTerm.toLowerCase())
    const matchesCategory = filterCategory === 'All' || m.category === filterCategory
    return matchesSearch && matchesCategory
  })

  const categories = ['All', ...new Set(materials.map(m => m.category))]

  return (
    <div className="app">
      <header className="app-header">
        <h1>🏗️ Construction Material Tracker</h1>
        <div className="stats">
          <span className="stat">📦 {materials.length} materials</span>
          {lowStockCount > 0 && (
            <span className="stat stat-warning">⚠️ {lowStockCount} low stock</span>
          )}
        </div>
      </header>

      <main className="app-main">
        <section className="form-section">
          <h2>{editingMaterial ? 'Edit Material' : 'Add New Material'}</h2>
          <MaterialForm
            onSubmit={editingMaterial ? updateMaterial : addMaterial}
            initialData={editingMaterial}
            onCancel={() => setEditingMaterial(null)}
          />
        </section>

        <section className="table-section">
          <div className="table-controls">
            <input
              type="text"
              placeholder="Search materials..."
              value={searchTerm}
              onChange={(e) => setSearchTerm(e.target.value)}
              className="search-input"
            />
            <select
              value={filterCategory}
              onChange={(e) => setFilterCategory(e.target.value)}
              className="filter-select"
            >
              {categories.map(cat => (
                <option key={cat} value={cat}>{cat}</option>
              ))}
            </select>
          </div>
          <MaterialTable
            materials={filteredMaterials}
            onEdit={setEditingMaterial}
            onDelete={deleteMaterial}
          />
        </section>
      </main>
    </div>
  )
}

export default App

What does this code do?

This file is the brain of your app. It manages four pieces of state: the materials array, an editingMaterial for tracking which item is being edited, a searchTerm, and a filterCategory.

The useState hook with a function (called a lazy initializer) loads saved materials from localStorage on first render. The useEffect hook saves materials back to localStorage whenever they change.

The addMaterial, updateMaterial, and deleteMaterial functions handle the three core CRUD operations. The return JSX renders a header with stats and two sections: one for the form and one for the material table.

Seeing an error in your browser? That's expected

Your browser will show an error because the MaterialForm and MaterialTable component files don't exist yet. You'll create them in the next substeps and the error will resolve.

  • Save src/App.jsx.
Create the material entry form component

The form is how users add new materials to the inventory. It needs fields for the material name, category, quantity, unit of measurement, and minimum stock level.

  • Create a new folder called components inside the src folder by running this command in your terminal:
mkdir src/components
  • Confirm the folder exists by running:
ls src
  • You should see components listed alongside App.jsx, App.css, and other files.
  • Create a new file called MaterialForm.jsx inside src/components by running:
touch src/components/MaterialForm.jsx
  • Open src/components/MaterialForm.jsx in VS Code and paste the following form component:
import { useState, useEffect } from 'react'

const CATEGORIES = ['Concrete', 'Lumber', 'Steel', 'Electrical', 'Plumbing', 'Finishing', 'Tools', 'Safety']
const UNITS = ['pieces', 'kg', 'm', 'm²', 'm³', 'bags', 'rolls', 'boxes', 'liters']

function MaterialForm({ onSubmit, initialData, onCancel }) {
  const [formData, setFormData] = useState({
    name: '',
    category: 'Concrete',
    quantity: '',
    unit: 'pieces',
    minStock: '',
  })

  useEffect(() => {
    if (initialData) {
      setFormData({
        name: initialData.name,
        category: initialData.category,
        quantity: initialData.quantity.toString(),
        unit: initialData.unit,
        minStock: initialData.minStock.toString(),
      })
    } else {
      setFormData({
        name: '',
        category: 'Concrete',
        quantity: '',
        unit: 'pieces',
        minStock: '',
      })
    }
  }, [initialData])

  const handleSubmit = (e) => {
    e.preventDefault()
    const material = {
      ...initialData,
      name: formData.name,
      category: formData.category,
      quantity: Number(formData.quantity),
      unit: formData.unit,
      minStock: Number(formData.minStock),
      dateAdded: initialData?.dateAdded || new Date().toISOString().split('T')[0],
    }
    onSubmit(material)
    if (!initialData) {
      setFormData({ name: '', category: 'Concrete', quantity: '', unit: 'pieces', minStock: '' })
    }
  }

  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value })
  }

  return (
    <form onSubmit={handleSubmit} className="material-form">
      <div className="form-row">
        <div className="form-group">
          <label htmlFor="name">Material Name</label>
          <input
            id="name"
            name="name"
            type="text"
            value={formData.name}
            onChange={handleChange}
            placeholder="e.g. Portland Cement"
            required
          />
        </div>
        <div className="form-group">
          <label htmlFor="category">Category</label>
          <select id="category" name="category" value={formData.category} onChange={handleChange}>
            {CATEGORIES.map(cat => (
              <option key={cat} value={cat}>{cat}</option>
            ))}
          </select>
        </div>
      </div>
      <div className="form-row">
        <div className="form-group">
          <label htmlFor="quantity">Quantity</label>
          <input
            id="quantity"
            name="quantity"
            type="number"
            min="0"
            value={formData.quantity}
            onChange={handleChange}
            placeholder="0"
            required
          />
        </div>
        <div className="form-group">
          <label htmlFor="unit">Unit</label>
          <select id="unit" name="unit" value={formData.unit} onChange={handleChange}>
            {UNITS.map(unit => (
              <option key={unit} value={unit}>{unit}</option>
            ))}
          </select>
        </div>
        <div className="form-group">
          <label htmlFor="minStock">Min. Stock Level</label>
          <input
            id="minStock"
            name="minStock"
            type="number"
            min="0"
            value={formData.minStock}
            onChange={handleChange}
            placeholder="0"
            required
          />
        </div>
      </div>
      <div className="form-actions">
        <button type="submit" className="btn btn-primary">
          {initialData ? 'Update Material' : 'Add Material'}
        </button>
        {initialData && (
          <button type="button" className="btn btn-secondary" onClick={onCancel}>
            Cancel
          </button>
        )}
      </div>
    </form>
  )
}

export default MaterialForm

What does this form component do?

The CATEGORIES and UNITS arrays define the dropdown options for construction material types and measurement units.

The useEffect hook watches the initialData prop. When it changes (meaning the user clicked Edit on a material), the form fields populate with that material's data. When it's null, the form resets to empty for adding new materials.

The submit button text switches between "Add Material" and "Update Material" depending on whether initialData exists. This single form handles both creating and editing.

  • Save src/components/MaterialForm.jsx.

Seeing a syntax error in the file?

Make sure the file is saved as .jsx (not .js). Vite needs the .jsx extension to process JSX syntax.

Check that every opening parenthesis, brace, and angle bracket has a matching closing one. A missing ) or } at the end of the file is a common cause.

help me find the syntax error in my MaterialForm.jsx file

Wire the form into the app and verify

Your App.jsx already imports and renders MaterialForm. Now that the file exists, the form-related error resolves. The MaterialTable import still errors because you'll create that component in the next step.

  • Create a placeholder MaterialTable.jsx file so the app can compile while you work on it in the next step:
touch src/components/MaterialTable.jsx
  • Open src/components/MaterialTable.jsx and add this temporary placeholder:
function MaterialTable({ materials, onEdit, onDelete }) {
  return null
}

export default MaterialTable

Why a placeholder component?

Vite cannot compile your app if an import points to a file that doesn't exist. This minimal placeholder satisfies the import so the rest of your app renders correctly. You'll replace it with the real table in the next step.

  • Save src/components/MaterialTable.jsx.
  • Check your browser at http://localhost:5173. You should see the header reading "Construction Material Tracker" with a materials counter showing 0, and the "Add New Material" form below it.

Page is blank or still showing an error?

Double-check that both src/components/MaterialForm.jsx and src/components/MaterialTable.jsx exist and are saved. Vite needs both files to resolve the imports in App.jsx.

If the terminal shows a different error, check that your file paths match exactly. The import uses './components/MaterialForm' so the folder must be components (lowercase).

help me fix the import error in my Vite React app

  • Test the form by entering a material name like Portland Cement, selecting a category, entering a quantity of 50, and clicking Add Material.
  • Open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
  • In the console, type localStorage.getItem('construction-materials') and press Enter. You should see a JSON array containing the material you just added.

What should I see?

The console output looks something like [{"name":"Portland Cement","category":"Concrete","quantity":50,...}]. This confirms your form is submitting data and localStorage persistence is already working.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point in the project.

:root {
  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #213547;
  background-color: #f5f5f5;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  padding: 2rem;
}
import { useState, useEffect } from 'react'
import MaterialForm from './components/MaterialForm'
import MaterialTable from './components/MaterialTable'
import './App.css'

const STORAGE_KEY = 'construction-materials'

function App() {
  const [materials, setMaterials] = useState(() => {
    const saved = localStorage.getItem(STORAGE_KEY)
    return saved ? JSON.parse(saved) : []
  })
  const [editingMaterial, setEditingMaterial] = useState(null)
  const [searchTerm, setSearchTerm] = useState('')
  const [filterCategory, setFilterCategory] = useState('All')

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(materials))
  }, [materials])

  const addMaterial = (material) => {
    setMaterials([...materials, { ...material, id: Date.now().toString() }])
  }

  const updateMaterial = (updatedMaterial) => {
    setMaterials(materials.map(m =>
      m.id === updatedMaterial.id ? updatedMaterial : m
    ))
    setEditingMaterial(null)
  }

  const deleteMaterial = (id) => {
    setMaterials(materials.filter(m => m.id !== id))
  }

  const lowStockCount = materials.filter(m => m.quantity <= m.minStock).length

  const filteredMaterials = materials.filter(m => {
    const matchesSearch = m.name.toLowerCase().includes(searchTerm.toLowerCase())
    const matchesCategory = filterCategory === 'All' || m.category === filterCategory
    return matchesSearch && matchesCategory
  })

  const categories = ['All', ...new Set(materials.map(m => m.category))]

  return (
    <div className="app">
      <header className="app-header">
        <h1>🏗️ Construction Material Tracker</h1>
        <div className="stats">
          <span className="stat">📦 {materials.length} materials</span>
          {lowStockCount > 0 && (
            <span className="stat stat-warning">⚠️ {lowStockCount} low stock</span>
          )}
        </div>
      </header>

      <main className="app-main">
        <section className="form-section">
          <h2>{editingMaterial ? 'Edit Material' : 'Add New Material'}</h2>
          <MaterialForm
            onSubmit={editingMaterial ? updateMaterial : addMaterial}
            initialData={editingMaterial}
            onCancel={() => setEditingMaterial(null)}
          />
        </section>

        <section className="table-section">
          <div className="table-controls">
            <input
              type="text"
              placeholder="Search materials..."
              value={searchTerm}
              onChange={(e) => setSearchTerm(e.target.value)}
              className="search-input"
            />
            <select
              value={filterCategory}
              onChange={(e) => setFilterCategory(e.target.value)}
              className="filter-select"
            >
              {categories.map(cat => (
                <option key={cat} value={cat}>{cat}</option>
              ))}
            </select>
          </div>
          <MaterialTable
            materials={filteredMaterials}
            onEdit={setEditingMaterial}
            onDelete={deleteMaterial}
          />
        </section>
      </main>
    </div>
  )
}

export default App
import { useState, useEffect } from 'react'

const CATEGORIES = ['Concrete', 'Lumber', 'Steel', 'Electrical', 'Plumbing', 'Finishing', 'Tools', 'Safety']
const UNITS = ['pieces', 'kg', 'm', 'm²', 'm³', 'bags', 'rolls', 'boxes', 'liters']

function MaterialForm({ onSubmit, initialData, onCancel }) {
  const [formData, setFormData] = useState({
    name: '',
    category: 'Concrete',
    quantity: '',
    unit: 'pieces',
    minStock: '',
  })

  useEffect(() => {
    if (initialData) {
      setFormData({
        name: initialData.name,
        category: initialData.category,
        quantity: initialData.quantity.toString(),
        unit: initialData.unit,
        minStock: initialData.minStock.toString(),
      })
    } else {
      setFormData({
        name: '',
        category: 'Concrete',
        quantity: '',
        unit: 'pieces',
        minStock: '',
      })
    }
  }, [initialData])

  const handleSubmit = (e) => {
    e.preventDefault()
    const material = {
      ...initialData,
      name: formData.name,
      category: formData.category,
      quantity: Number(formData.quantity),
      unit: formData.unit,
      minStock: Number(formData.minStock),
      dateAdded: initialData?.dateAdded || new Date().toISOString().split('T')[0],
    }
    onSubmit(material)
    if (!initialData) {
      setFormData({ name: '', category: 'Concrete', quantity: '', unit: 'pieces', minStock: '' })
    }
  }

  const handleChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value })
  }

  return (
    <form onSubmit={handleSubmit} className="material-form">
      <div className="form-row">
        <div className="form-group">
          <label htmlFor="name">Material Name</label>
          <input
            id="name"
            name="name"
            type="text"
            value={formData.name}
            onChange={handleChange}
            placeholder="e.g. Portland Cement"
            required
          />
        </div>
        <div className="form-group">
          <label htmlFor="category">Category</label>
          <select id="category" name="category" value={formData.category} onChange={handleChange}>
            {CATEGORIES.map(cat => (
              <option key={cat} value={cat}>{cat}</option>
            ))}
          </select>
        </div>
      </div>
      <div className="form-row">
        <div className="form-group">
          <label htmlFor="quantity">Quantity</label>
          <input
            id="quantity"
            name="quantity"
            type="number"
            min="0"
            value={formData.quantity}
            onChange={handleChange}
            placeholder="0"
            required
          />
        </div>
        <div className="form-group">
          <label htmlFor="unit">Unit</label>
          <select id="unit" name="unit" value={formData.unit} onChange={handleChange}>
            {UNITS.map(unit => (
              <option key={unit} value={unit}>{unit}</option>
            ))}
          </select>
        </div>
        <div className="form-group">
          <label htmlFor="minStock">Min. Stock Level</label>
          <input
            id="minStock"
            name="minStock"
            type="number"
            min="0"
            value={formData.minStock}
            onChange={handleChange}
            placeholder="0"
            required
          />
        </div>
      </div>
      <div className="form-actions">
        <button type="submit" className="btn btn-primary">
          {initialData ? 'Update Material' : 'Add Material'}
        </button>
        {initialData && (
          <button type="button" className="btn btn-secondary" onClick={onCancel}>
            Cancel
          </button>
        )}
      </div>
    </form>
  )
}

export default MaterialForm
function MaterialTable({ materials, onEdit, onDelete }) {
  return null
}

export default MaterialTable

Your app shell is in place and the form is working. Next up, you'll build the material inventory table that displays all your materials with edit, delete, and low-stock indicators.

Build the Material Inventory Table

You have a form that can add materials to state, but there's no way to see what's been added. A tracker without visibility is like a warehouse without labels.

In this step, you'll create the MaterialTable component that displays your inventory in a structured table. Each row shows the material's details, a visual stock-status indicator, and action buttons for editing and deleting entries.

In this step, get ready to:
  • Create a MaterialTable component that renders materials in a table with category badges and low-stock indicators.
  • Add Edit and Delete buttons that connect to the existing app state.
  • Verify the full add, edit, and delete workflow end-to-end.
Create the MaterialTable component

The App.jsx file already imports and renders a MaterialTable component, passing it the filtered materials list and handler functions. Right now your browser likely shows an error because the file doesn't exist yet.

  • Create a new file at src/components/MaterialTable.jsx and add the following code:
function MaterialTable({ materials, onEdit, onDelete }) {
  // Show a friendly message when the table is empty
  if (materials.length === 0) {
    return <p className="empty-message">No materials found. Add your first material above!</p>
  }

  return (
    <div className="table-wrapper">
      <table className="material-table">
        <thead>
          <tr>
            <th>Material</th>
            <th>Category</th>
            <th>Quantity</th>
            <th>Min. Stock</th>
            <th>Status</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {materials.map(material => {
            // Compare current quantity against the minimum threshold
            const isLowStock = material.quantity <= material.minStock
            return (
              <tr key={material.id} className={isLowStock ? 'low-stock' : ''}>
                <td>{material.name}</td>
                <td><span className="category-badge">{material.category}</span></td>
                <td>{material.quantity} {material.unit}</td>
                <td>{material.minStock} {material.unit}</td>
                <td>
                  {isLowStock ? (
                    <span className="status-badge status-low">⚠️ Low Stock</span>
                  ) : (
                    <span className="status-badge status-ok">✓ In Stock</span>
                  )}
                </td>
                <td className="actions-cell">
                  <button className="btn btn-small btn-edit" onClick={() => onEdit(material)}>
                    Edit
                  </button>
                  <button className="btn btn-small btn-delete" onClick={() => onDelete(material.id)}>
                    Delete
                  </button>
                </td>
              </tr>
            )
          })}
        </tbody>
      </table>
    </div>
  )
}

export default MaterialTable

What does this code do?

This component receives three props from App.jsx: the list of materials to display, and two handler functions for editing and deleting.

  • The empty-state check returns a message paragraph when there are no materials, preventing an empty table from rendering.
  • isLowStock compares each material's current quantity against its minimum threshold. When stock is at or below the minimum, the row gets a low-stock class and the status badge shows a warning.
  • The Edit button passes the entire material object to onEdit, which populates the form above. The Delete button passes just the ID to onDelete, which removes it from state.
  • Save src/components/MaterialTable.jsx.
  • Refresh your browser at http://localhost:5173. You should see the message "No materials found. Add your first material above!" below the search bar and category dropdown.

Still seeing an error in the browser?

  • Check that the file is saved at exactly src/components/MaterialTable.jsx. The import in App.jsx expects this exact path.
  • Make sure the function name MaterialTable matches the export default MaterialTable at the bottom of the file.
  • help me fix an import error for MaterialTable
Test the full add, edit, and delete workflow

The form you built in the previous step already supports edit mode. When you click Edit on a table row, the material's data populates the form and the button text switches to "Update Material". Time to verify the complete flow.

  • In the form, enter Portland Cement as the material name.
  • Select Concrete as the category.
  • Enter 50 for quantity, bags for unit, and 100 for min. stock level.
  • Click Add Material.

You should see a new row in the table with a yellow background and a "Low Stock" warning badge. The quantity (50) is at or below the minimum (100), so the alert triggers.

  • Click the Edit button on the Portland Cement row.
  • Notice the form above fills in with the material's values and the button now says Update Material.
  • Change the quantity to 200 and click Update Material.

The row should now show 200 bags with a green "In Stock" badge. The low-stock warning in the header disappears because no materials are below their threshold.

  • Click the Delete button on the Portland Cement row.
  • Confirm the row disappears and the "No materials found" message returns.

How does the edit flow work?

When you click Edit, the table calls onEdit(material) which maps to setEditingMaterial in App. This stores the selected material in state.

The form's useEffect watches for changes to its initialData prop. When a material is passed in, the effect populates the form fields. When the learner submits, updateMaterial replaces the old entry in the array and clears the editing state.

Edit button not populating the form?

  • Make sure the onClick on the Edit button passes the full material object, not just the ID.
  • Check that MaterialForm.jsx has a useEffect with [initialData] in its dependency array.
  • help me debug why clicking Edit doesn't fill in the form

✔️ Awesome, I've got everything!

Great. Double check you've saved MaterialTable.jsx and that all three operations (add, edit, delete) work in the browser.

ⓧ I'd like to double check the full code

Here is the complete MaterialTable.jsx file:

function MaterialTable({ materials, onEdit, onDelete }) {
  if (materials.length === 0) {
    return <p className="empty-message">No materials found. Add your first material above!</p>
  }

  return (
    <div className="table-wrapper">
      <table className="material-table">
        <thead>
          <tr>
            <th>Material</th>
            <th>Category</th>
            <th>Quantity</th>
            <th>Min. Stock</th>
            <th>Status</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {materials.map(material => {
            const isLowStock = material.quantity <= material.minStock
            return (
              <tr key={material.id} className={isLowStock ? 'low-stock' : ''}>
                <td>{material.name}</td>
                <td><span className="category-badge">{material.category}</span></td>
                <td>{material.quantity} {material.unit}</td>
                <td>{material.minStock} {material.unit}</td>
                <td>
                  {isLowStock ? (
                    <span className="status-badge status-low">⚠️ Low Stock</span>
                  ) : (
                    <span className="status-badge status-ok">✓ In Stock</span>
                  )}
                </td>
                <td className="actions-cell">
                  <button className="btn btn-small btn-edit" onClick={() => onEdit(material)}>
                    Edit
                  </button>
                  <button className="btn btn-small btn-delete" onClick={() => onDelete(material.id)}>
                    Delete
                  </button>
                </td>
              </tr>
            )
          })}
        </tbody>
      </table>
    </div>
  )
}

export default MaterialTable

Your inventory table is fully functional with add, edit, and delete operations. Next up, you'll add localStorage persistence so your materials survive a page refresh, plus search and filtering to find materials quickly.

Add Persistence, Search, and Styling

Your material tracker can add, edit, and delete items. But right now, if you refresh the page, everything disappears.

In this step, you will verify that your app's data persistence and filtering features work correctly, then transform the unstyled layout into a polished dashboard with custom CSS.

In this step, get ready to:
  • Confirm localStorage persistence survives a page refresh.
  • Test search and category filtering on your materials.
  • Style the entire dashboard with custom CSS.
Verify localStorage persistence

The persistence code you wrote in App.jsx saves materials to the browser's localStorage every time the state changes. Let's confirm this actually works.

  • Open your app in the browser at http://localhost:5173.
  • Use the form to add a test material. Enter Portland Cement as the name, select Concrete as the category, set quantity to 50, unit to bags, and minimum stock to 20.
  • Click Add Material.
  • Refresh the page by pressing Cmd+R (macOS) or Ctrl+R (Windows).

Your Portland Cement entry should still appear in the table after the refresh.

How does localStorage persistence work?

When you initialize state with a function (a lazy initializer), React runs that function once on mount. Your initializer checks localStorage for saved data and uses it if found.

The useEffect hook watches the materials array for changes. Every time you add, edit, or delete a material, it writes the updated array to localStorage under the key construction-materials.

Material disappeared after refresh?

Check that the STORAGE_KEY constant in App.jsx is set to 'construction-materials'.

Make sure your useEffect has [materials] as its dependency array. Without it, state changes won't trigger a save.

If you're using incognito/private browsing, some browsers restrict localStorage access. Help me debug why my materials aren't persisting after refresh.

Test search and category filtering

Your app already has search and filter controls wired up. Let's add more materials and confirm both features work correctly.

  • Add a second material: Copper Wire in the Electrical category, quantity 10 rolls, minimum stock 15.
  • Add a third material: Rebar 12mm in the Steel category, quantity 200 pieces, minimum stock 50.
  • Type copper into the search field.
  • Confirm that only Copper Wire appears in the table.
  • Clear the search field, then select Concrete from the category dropdown.
  • Confirm that only Portland Cement appears.

How does filtering work here?

The filteredMaterials variable in App.jsx runs Array.filter on every render. It checks two conditions: whether the material name includes the search term (case-insensitive), and whether the category matches the dropdown selection.

Only the filtered results get passed to MaterialTable. The original materials array stays untouched in state.

What about the low-stock counter?

Notice that Copper Wire (quantity 10, minimum 15) triggers the low-stock warning in the header. The counter only appears when at least one material has a quantity at or below its minimum stock level.

Style the dashboard with custom CSS

Your app works, but it still has the default Vite styles. Let's replace them with a professional dashboard look.

  • Open src/App.css in your editor.
  • Select all the existing content and delete it.
  • Add the app layout and header styles by pasting this code:
.app {
  max-width: 1100px;
  margin: 0 auto;
}

.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 2rem;
  padding-bottom: 1rem;
  border-bottom: 2px solid #e0e0e0;
}

.app-header h1 {
  font-size: 1.8rem;
  color: #1a1a2e;
}

.stats {
  display: flex;
  gap: 1rem;
}

.stat {
  padding: 0.5rem 1rem;
  background: #e8f5e9;
  border-radius: 8px;
  font-size: 0.9rem;
  font-weight: 500;
}

.stat-warning {
  background: #fff3e0;
  color: #e65100;
}

.app-main {
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

What do these styles do?

  • .app centers the entire dashboard and caps it at 1100px wide.
  • .app-header uses flexbox to push the title left and the stats badges right, with a bottom border separating the header from content.
  • .stat and .stat-warning create pill-shaped badges. The warning variant uses an orange palette to draw attention to low-stock items.
  • Save src/App.css.
  • Check your browser. You should see the header centered on the page with the title on the left and stats badges on the right, separated by a gray bottom border.

Header not centered or badges missing?

Make sure you deleted all the old Vite styles before pasting. Leftover rules can conflict with your new layout.

Confirm the class name app-header in your CSS matches className="app-header" in your JSX.

Help me debug why my App.css styles aren't showing up.

  • Below the styles you just pasted, add the form section and input styles:
.form-section,
.table-section {
  background: white;
  padding: 1.5rem;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.form-section h2 {
  margin-bottom: 1rem;
  color: #1a1a2e;
}

.material-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.form-row {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  flex: 1;
  min-width: 150px;
}

.form-group label {
  font-size: 0.85rem;
  font-weight: 600;
  color: #555;
}

.form-group input,
.form-group select {
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
  transition: border-color 0.2s;
}

.form-group input:focus,
.form-group select:focus {
  outline: none;
  border-color: #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}

.form-actions {
  display: flex;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

What do these styles do?

  • .form-section and .table-section share the same card-like appearance with white backgrounds, rounded corners, and subtle shadows.
  • .form-row with flex-wrap: wrap makes the form inputs stack on smaller screens.
  • The :focus styles add a blue ring around inputs when selected, giving clear visual feedback.
  • Save src/App.css.
  • Check your browser. The form and table sections should now appear as white cards with rounded corners and soft shadows.

Form inputs not styled?

Check that class names in your CSS match exactly. For example, material-form in CSS must match className="material-form" in MaterialForm.jsx.

Help me figure out why my form styles aren't applying.

  • Below the form styles, add the button styles:
.btn {
  padding: 0.6rem 1.2rem;
  border: none;
  border-radius: 6px;
  font-size: 0.9rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}

.btn-primary {
  background: #1976d2;
  color: white;
}

.btn-primary:hover {
  background: #1565c0;
}

.btn-secondary {
  background: #e0e0e0;
  color: #333;
}

.btn-secondary:hover {
  background: #bdbdbd;
}

.btn-small {
  padding: 0.3rem 0.7rem;
  font-size: 0.8rem;
}

.btn-edit {
  background: #e3f2fd;
  color: #1976d2;
}

.btn-edit:hover {
  background: #bbdefb;
}

.btn-delete {
  background: #fce4ec;
  color: #c62828;
}

.btn-delete:hover {
  background: #f8bbd0;
}

What do these styles do?

  • .btn is the base button style shared by all buttons. It removes the default border, adds rounded corners, and includes a hover transition.
  • .btn-primary creates a blue action button for the form submit.
  • .btn-edit and .btn-delete use light color-coded backgrounds (blue for edit, red for delete) so actions are visually distinct in the table.
  • Save src/App.css.
  • Check your browser. The Add Material button should be blue, and the Edit/Delete buttons in each table row should have distinct color-coded backgrounds.
  • Finally, add the table controls and table styles below your button styles:
.table-controls {
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
}

.search-input {
  flex: 1;
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
}

.search-input:focus {
  outline: none;
  border-color: #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}

.filter-select {
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
  min-width: 140px;
}

.filter-select:focus {
  outline: none;
  border-color: #1976d2;
}

.table-wrapper {
  overflow-x: auto;
}

.material-table {
  width: 100%;
  border-collapse: collapse;
}

.material-table th,
.material-table td {
  padding: 0.75rem;
  text-align: left;
  border-bottom: 1px solid #eee;
}

.material-table th {
  background: #f5f5f5;
  font-size: 0.85rem;
  text-transform: uppercase;
  color: #666;
  font-weight: 600;
}

.material-table tr:hover {
  background: #fafafa;
}

.material-table tr.low-stock {
  background: #fff8e1;
}

.material-table tr.low-stock:hover {
  background: #fff3e0;
}

.category-badge {
  padding: 0.2rem 0.6rem;
  background: #e8eaf6;
  border-radius: 4px;
  font-size: 0.8rem;
  color: #3949ab;
}

.status-badge {
  padding: 0.2rem 0.6rem;
  border-radius: 4px;
  font-size: 0.8rem;
  font-weight: 500;
}

.status-ok {
  background: #e8f5e9;
  color: #2e7d32;
}

.status-low {
  background: #fff3e0;
  color: #e65100;
}

.actions-cell {
  display: flex;
  gap: 0.4rem;
}

.empty-message {
  text-align: center;
  padding: 2rem;
  color: #888;
  font-style: italic;
}

What do these styles do?

  • .table-controls places the search input and category dropdown side by side above the table.
  • .material-table tr.low-stock highlights rows with a yellow background when a material's quantity is at or below its minimum stock level.
  • .category-badge and .status-badge create small pill-shaped labels. The status badge switches between green (in stock) and orange (low stock).
  • Save src/App.css.
  • Check your browser. You should see a fully styled dashboard with a clean table, category badges in purple, green "In Stock" badges, and any low-stock rows highlighted in yellow.

Low-stock row not highlighted?

The yellow highlight applies to rows with the class low-stock. This class is added by MaterialTable.jsx when material.quantity <= material.minStock. Make sure you have at least one material where the quantity is at or below the minimum stock level (like the Copper Wire you added earlier with quantity 10 and minimum 15).

Help me figure out why low-stock highlighting isn't working.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/App.css with all four style groups (layout, form, buttons, table).

ⓧ I'd like to double check the full code

.app {
  max-width: 1100px;
  margin: 0 auto;
}

.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 2rem;
  padding-bottom: 1rem;
  border-bottom: 2px solid #e0e0e0;
}

.app-header h1 {
  font-size: 1.8rem;
  color: #1a1a2e;
}

.stats {
  display: flex;
  gap: 1rem;
}

.stat {
  padding: 0.5rem 1rem;
  background: #e8f5e9;
  border-radius: 8px;
  font-size: 0.9rem;
  font-weight: 500;
}

.stat-warning {
  background: #fff3e0;
  color: #e65100;
}

.app-main {
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

.form-section,
.table-section {
  background: white;
  padding: 1.5rem;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.form-section h2 {
  margin-bottom: 1rem;
  color: #1a1a2e;
}

.material-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.form-row {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  flex: 1;
  min-width: 150px;
}

.form-group label {
  font-size: 0.85rem;
  font-weight: 600;
  color: #555;
}

.form-group input,
.form-group select {
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
  transition: border-color 0.2s;
}

.form-group input:focus,
.form-group select:focus {
  outline: none;
  border-color: #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}

.form-actions {
  display: flex;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

.btn {
  padding: 0.6rem 1.2rem;
  border: none;
  border-radius: 6px;
  font-size: 0.9rem;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}

.btn-primary {
  background: #1976d2;
  color: white;
}

.btn-primary:hover {
  background: #1565c0;
}

.btn-secondary {
  background: #e0e0e0;
  color: #333;
}

.btn-secondary:hover {
  background: #bdbdbd;
}

.btn-small {
  padding: 0.3rem 0.7rem;
  font-size: 0.8rem;
}

.btn-edit {
  background: #e3f2fd;
  color: #1976d2;
}

.btn-edit:hover {
  background: #bbdefb;
}

.btn-delete {
  background: #fce4ec;
  color: #c62828;
}

.btn-delete:hover {
  background: #f8bbd0;
}

.table-controls {
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
}

.search-input {
  flex: 1;
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
}

.search-input:focus {
  outline: none;
  border-color: #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.1);
}

.filter-select {
  padding: 0.6rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 0.95rem;
  min-width: 140px;
}

.filter-select:focus {
  outline: none;
  border-color: #1976d2;
}

.table-wrapper {
  overflow-x: auto;
}

.material-table {
  width: 100%;
  border-collapse: collapse;
}

.material-table th,
.material-table td {
  padding: 0.75rem;
  text-align: left;
  border-bottom: 1px solid #eee;
}

.material-table th {
  background: #f5f5f5;
  font-size: 0.85rem;
  text-transform: uppercase;
  color: #666;
  font-weight: 600;
}

.material-table tr:hover {
  background: #fafafa;
}

.material-table tr.low-stock {
  background: #fff8e1;
}

.material-table tr.low-stock:hover {
  background: #fff3e0;
}

.category-badge {
  padding: 0.2rem 0.6rem;
  background: #e8eaf6;
  border-radius: 4px;
  font-size: 0.8rem;
  color: #3949ab;
}

.status-badge {
  padding: 0.2rem 0.6rem;
  border-radius: 4px;
  font-size: 0.8rem;
  font-weight: 500;
}

.status-ok {
  background: #e8f5e9;
  color: #2e7d32;
}

.status-low {
  background: #fff3e0;
  color: #e65100;
}

.actions-cell {
  display: flex;
  gap: 0.4rem;
}

.empty-message {
  text-align: center;
  padding: 2rem;
  color: #888;
  font-style: italic;
}

Your construction material tracker is now a polished, professional dashboard. It persists data across refreshes, lets you search and filter materials instantly, and highlights low-stock items so nothing slips through the cracks. Next up, you can take on the Secret Mission to add usage tracking.

Secret mission

Add a Material Usage Log

Your tracker knows what's in stock, but it doesn't know where materials went. Add a Usage Log that records every time materials are consumed, showing a history of who used what, how much, and when.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The material-tracker project folder on your Desktop.
  • The Vite development server running at http://localhost:5173.
  • Browser localStorage data under the keys construction-materials and construction-usage-log.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with the app.

  • Your dev server stays running and accessible at http://localhost:5173.
  • Your material data stays in localStorage until you clear it manually.
  • Node.js remains installed on your machine for future projects.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal. Your project files and localStorage data stay intact for when you return.

  • Go to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • When you come back, navigate to the material-tracker folder and run npm run dev to start again.

Delete - I don't want to use this again

Remove all project files and stored data to start completely fresh.

  • Go to the terminal where the dev server is running and press Ctrl+C to stop it.
  • Delete the project folder by running the following command from your Desktop.

macOS

cd ~/Desktop
rm -rf material-tracker

Windows

cd ~\Desktop
rmdir /s /q material-tracker
  • Clear your localStorage data by opening your browser's developer tools.
  • Go to the Application tab (Chrome) or Storage tab (Firefox).
  • Expand Local Storage, select http://localhost:5173, and click Clear All.

Nice Work!

Nice Work!

Nice work! You just built a professional construction material management dashboard from scratch using React and Vite.

You've learned how to:

  • Built reusable React components (MaterialForm, MaterialTable) with props, state, and full CRUD operations (Create, Read, Update, Delete).
  • Added browser-based data persistence using localStorage so materials survive page refreshes.
  • Implemented search and category filtering with visual low-stock alerts using conditional styling.
  • Added a material usage log to track consumption history over time.

Ready to quiz yourself?