A fast, fully client-side grocery list manager built with React 19, TypeScript, Vite, and Tailwind CSS v4. Add items with quantities, edit them in place, and delete when done. Everything is saved to the browser's localStorage with no backend, no account, and only minimal on-demand network activity for lazily loaded features after the initial page load.
Live demo: https://milliorn.github.io/grocery-list/
- Lighthouse Score
- Features
- Tech Stack
- Project Structure
- Prerequisites
- Installation
- Running the App
- Available Scripts
- Architecture Overview
- Configuration
- Deployment
- PWA Support
- CI/CD and Automation
- Browser Support
- License
- Acknowledgements
- Links
The application is optimized for performance, accessibility, best practices, and SEO.
- Add items: A togglable form lets you enter an item name and a quantity before saving.
- Quantity tracking: Every item stores a free-text quantity field (e.g. "2 loaves", "500 g", "1 dozen").
- Edit items: A SweetAlert2 dialog pre-fills the current values so you can update the name or quantity without deleting and re-adding.
- Delete items: Remove any item from the list with a single click.
- Live item count: The heading always shows the current number of items remaining in the list.
- Form validation: Client-side checks prevent saving incomplete entries. SweetAlert2 alerts tell you exactly what is missing.
- Data persistence: The list survives page refreshes because it is serialised to
localStorage. If the storage is corrupt, it is removed gracefully on startup. - Storage error recovery: If a write to
localStoragefails (e.g. quota exceeded) the previous list is restored in memory and an error alert is shown. - Responsive layout: Works on phones, tablets, and desktops. The content area has a maximum width of 672 px and scales text with Tailwind's responsive prefixes (
sm:,md:,lg:,xl:,2xl:). - Progressive Web App (PWA): Includes a
manifest.jsonwith multiple icon sizes, a theme colour, anddisplay: standaloneso the app can be added to a device home screen. - Accessibility: Icon buttons carry
aria-labelattributes so screen-reader users know their purpose. - Dark theme: The UI uses a zinc-900 dark background with zinc-50 light text.
- Random background photo: A full-screen decorative image is served from Picsum Photos each render.
| Category | Technology |
|---|---|
| UI library | React |
| Language | TypeScript |
| Build tool | Vite |
| React plugin | @vitejs/plugin-react-swc |
| CSS framework | Tailwind CSS |
| Tailwind Vite plugin | @tailwindcss/vite |
| Tailwind PostCSS | @tailwindcss/postcss |
| PostCSS | postcss |
| Autoprefixer | autoprefixer |
| Alert dialogs | SweetAlert2 (lazy-loaded) |
| Linter | ESLint |
| TypeScript ESLint | typescript-eslint |
| React Hooks lint | eslint-plugin-react-hooks |
| React Refresh lint | eslint-plugin-react-refresh |
| Formatter | Prettier |
| Deployment | gh-pages |
No backend, no database, no environment variables are required.
grocery-list/
├── .github/
│ ├── dependabot.yml # Monthly dependency updates (npm + Actions)
│ └── workflows/
│ └── automerge.yml # Auto-approve/merge Dependabot patch & minor PRs
├── public/
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── favicon.ico
│ ├── lighthouse.png # Lighthouse score screenshot shown in README
│ ├── manifest.json # PWA manifest
│ └── robots.txt
├── src/
│ ├── components/
│ │ ├── AddGroceryItem.tsx # Form for adding new items
│ │ ├── Button.tsx # Reusable styled button (red / green)
│ │ ├── DeleteItem.tsx # Red X icon button
│ │ ├── EditItem.tsx # Blue pencil icon button
│ │ ├── GroceryItem.tsx # Single item card (quantity + controls)
│ │ ├── Header.tsx # App title + Add/Close toggle button
│ │ ├── ItemEditDelete.tsx # Groups EditItem and DeleteItem together
│ │ ├── Items.tsx # Renders the full list of GroceryItem cards
│ │ └── ItemsQuantity.tsx # Displays item name and quantity text
│ ├── props/
│ │ ├── AddGroceryItemProps.ts
│ │ ├── ButtonProps.ts
│ │ ├── DeleteItemProps.ts
│ │ ├── EditItemProps.ts
│ │ ├── GroceryItem.ts # Core GroceryItem type definition
│ │ ├── GroceryItemProps.ts
│ │ ├── HeaderProps.ts
│ │ ├── ItemEditDeleteProps.ts
│ │ ├── ItemsProps.ts
│ │ └── ItemsQuantityProps.ts
│ ├── styles/
│ │ └── index.css # Tailwind import + body background styles
│ ├── utils/
│ │ └── getSwal.ts # Lazy-loads and returns the SweetAlert2 default export
│ ├── App.tsx # Root component; all state and CRUD logic lives here
│ ├── constants.ts # STORAGE_KEY constant for localStorage
│ ├── main.tsx # React DOM root; mounts App in StrictMode
│ └── vite-env.d.ts # Vite client type declarations
├── .gitignore
├── .prettierignore
├── .prettierrc # Prettier config: no semicolons, no trailing commas
├── eslint.config.js # ESLint flat config (type-aware)
├── index.html # HTML entry point; mounts React via div#root
├── package.json
├── package-lock.json
├── postcss.config.cjs # PostCSS config: Tailwind + autoprefixer
├── tsconfig.app.json # TypeScript config for src/ (strict + extra checks)
├── tsconfig.json # Composite root; references app + node configs
├── tsconfig.node.json # TypeScript config for Vite config file (Node env)
└── vite.config.ts # Vite config: React-SWC plugin, base path, PostCSS
| Requirement | Minimum version | Notes |
|---|---|---|
| Node.js | 18.x | React 19 and Vite 7 both require Node 18+. Node 20 LTS is recommended. |
| npm | 9.x | Bundled with Node 18+. Yarn or pnpm also work. |
| Git | Any recent | Only needed to clone the repository. |
You do not need any API keys, environment variables, or accounts to run this project.
git clone https://github.com/milliorn/Grocery-List.git
cd Grocery-Listnpm installThis installs both the runtime dependencies (React, SweetAlert2, etc.) and all development tools (Vite, TypeScript, ESLint, Prettier, Tailwind CSS, etc.).
Development server (hot-reload via Vite + SWC):
npm run devOpen http://localhost:5173/grocery-list/ in your browser. The page updates instantly as you edit source files, with no manual reload needed.
Production preview (test the built output locally):
npm run build
npm run previewAll scripts are defined in package.json and run with npm run <script>.
| Script | Command | Description |
|---|---|---|
dev |
vite |
Starts the Vite development server with hot module replacement. |
build |
tsc -b && vite build |
Type-checks with TypeScript, then produces an optimised production bundle in dist/. |
preview |
vite preview |
Serves the contents of dist/ locally so you can verify the production build before deploying. |
lint |
eslint . |
Runs ESLint across all TypeScript/TSX files using the flat config in eslint.config.js. |
prettier-check |
npx prettier . --check |
Checks that all files match the Prettier formatting rules without changing them. Useful in CI. |
prettier-write |
npx prettier . --write |
Formats all files in place according to .prettierrc. Run this before committing. |
tsc-watch |
tsc -w |
Watches TypeScript files and reports type errors on every save without building. |
deploy-before |
npm run build |
Alias for build. Called automatically by the deploy script to ensure the bundle is fresh. |
deploy |
gh-pages -d dist |
Pushes the dist/ directory to the gh-pages branch, publishing the site to GitHub Pages. |
App <- State, CRUD logic, localStorage I/O
├── Header <- Title + toggle button
│ └── Button <- Reusable green/red button
├── AddGroceryItem <- Togglable add form (conditionally rendered)
└── Items <- List container
└── GroceryItem (x N) <- One card per item
├── ItemsQuantity <- Displays item name and quantity
└── ItemEditDelete <- Groups edit + delete icons
├── EditItem <- Pencil icon -> opens SweetAlert2 edit dialog
└── DeleteItem <- X icon -> calls onDelete handler
The root component. It owns the entire application state and exposes CRUD callbacks to its children via props.
- State:
items: GroceryItem[]: the current grocery list.showItem: boolean: controls whether the add form is visible.
- On mount (
useEffect): readslocalStoragewith the key"itemAdded", parses JSON, and initialisesitems. Invalid/corrupt data is silently removed from storage. createItem: generates a UUID, appends the new item, writes tolocalStorage, and shows a success alert. On storage failure it rolls back to the previous list.deleteItem: filters out the item byid, writes tolocalStorage, and alerts on success or failure.updateItem(async): opens a SweetAlert2 dialog pre-filled with the current values. On confirmation it maps over the list, replaces the matching item, and persists tolocalStorage.
SweetAlert2 is lazy-loaded via dynamic import() in all three handlers so the library (~135 KiB) is excluded from the initial bundle and only fetched the first time an alert or dialog fires.
Displays the "Grocery List" heading and a Button that toggles the add form. When the form is open the button reads "Close" (red); when closed it reads "Add" (green).
A controlled form with two text inputs: Item and Quantity. On submit it validates both fields:
- Both empty → error alert: "Add item and quantity or close the form."
- Only quantity filled → error alert: "Add your item."
- Only item filled → error alert: "Add your quantity."
- Both filled → calls
onSaveand resets the inputs.
Maps the items array to a GroceryItem component for each entry, passing along onDelete and onEdit callbacks. Uses item.id as the React list key.
A single item card laid out as a horizontal flex row. Renders ItemsQuantity on the left and ItemEditDelete on the right.
Renders two labelled paragraphs:
Item: Bread
Quantity: 2 loaves
Responsive typography scales from the default size up through sm:, md:, lg:, and xl: breakpoints.
A simple wrapper that renders DeleteItem above EditItem, grouping the two action controls for a single grocery item.
Renders a blue pencil icon (inline SVG) as a <button>. Clicking it calls onEdit(item.id), which triggers the async SweetAlert2 dialog in App.
Renders a red X icon (inline SVG) as a <button>. Clicking it calls onDelete(item.id).
A reusable button component accepting three props:
| Prop | Type | Description |
|---|---|---|
color |
"red" | "green" |
Selects the Tailwind background class (bg-red-600 or bg-green-800). |
text |
string |
Label rendered inside the button. |
onClick |
() => void |
Click handler. |
Defined in src/props/GroceryItem.ts:
export type GroceryItem = {
id: string // UUID generated by crypto.randomUUID() (native browser API)
text: string // Display name of the grocery item (e.g. "Bread")
quantity: string // Free-text quantity (e.g. "2 loaves", "500 g", "1 dozen")
}All prop interfaces live as individual files in src/props/ to keep component files clean and make each interface independently importable.
There is no external state management library. All state lives in App.tsx using React's built-in useState hook:
items -> GroceryItem[] (the list)
showItem -> boolean (form visibility)
Callbacks (createItem, deleteItem, updateItem) are defined in App and passed down as props to the components that need them. This is a deliberate choice for a small application, avoiding Redux, Zustand, or Context API overhead.
Items are stored in localStorage under the key "itemAdded" (defined in src/constants.ts).
Read flow:
- On first render (
useEffectwith empty dependency array),AppcallslocalStorage.getItem("itemAdded"). - If the value is not
nulland not empty, it is parsed withJSON.parseand used to initialiseitems. - If parsing throws (corrupt data), the key is removed and
itemsstays as an empty array.
Write flow (every mutation):
- State is updated optimistically.
localStorage.setItem("itemAdded", JSON.stringify(updatedItems))is called inside atry/catch.- On success, a SweetAlert2 success dialog is shown.
- On failure (e.g. quota exceeded), the previous state snapshot is restored and an error dialog is shown.
File: vite.config.ts
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react-swc"
export default defineConfig({
plugins: [react()], // SWC-powered JSX transform + Fast Refresh
css: {
postcss: "./postcss.config.cjs" // Points PostCSS at the CJS config file
},
base: "/grocery-list/" // Required for GitHub Pages sub-path hosting
})Key points:
- SWC replaces Babel as the JSX/TS transpiler, making builds and HMR significantly faster.
- The
baseoption is set to/grocery-list/to match the GitHub Pages URL. If you self-host at a root domain, change this to"/".
Two TypeScript configs are used (project references pattern):
tsconfig.json is the composite root. It contains no compiler options of its own and simply references the two child configs:
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}tsconfig.app.json governs all files in src/. Key settings:
| Option | Value | Effect |
|---|---|---|
target |
ES2020 |
Output syntax (Vite transpiles further for older browsers) |
strict |
true |
Enables the full TypeScript strict suite |
noEmit |
true |
TypeScript only type-checks; Vite handles bundling |
jsx |
react-jsx |
Uses the modern React JSX transform (no import React needed) |
moduleResolution |
bundler |
Optimised for Vite/bundler environments |
exactOptionalPropertyTypes |
true |
Prevents assigning undefined to optional props accidentally |
noImplicitReturns |
true |
Every code path in a function must return a value |
noUnusedLocals |
true |
Errors on declared but unused variables |
noUnusedParameters |
true |
Errors on declared but unused function parameters |
useUnknownInCatchVariables |
true |
Catch clause variable typed as unknown, not any |
tsconfig.node.json governs vite.config.ts only. It targets the Node.js environment and uses tsconfig.node.json for project-service type information.
This project uses Tailwind CSS v4, which has a fundamentally different setup from v3:
-
There is no
tailwind.config.js. Configuration is handled by the plugins themselves. -
The CSS entry point (
src/styles/index.css) uses a single import:@import "tailwindcss";
-
The Tailwind PostCSS plugin (
@tailwindcss/postcss) is registered inpostcss.config.cjs.
All Tailwind classes used in components are standard utility classes (no custom theme extensions or plugins).
File: postcss.config.cjs
Written as a CommonJS module (.cjs) because it is loaded by Vite's PostCSS integration at build time in a Node context:
module.exports = {
plugins: [
require("@tailwindcss/postcss"), // Processes Tailwind utilities
require("autoprefixer") // Adds vendor prefixes for cross-browser CSS
]
}File: eslint.config.js (ESLint v9+ flat config)
The configuration extends:
@eslint/jsrecommended rulestypescript-eslintrecommended rules (type-aware)eslint-plugin-react-hooksrecommended ruleseslint-plugin-react-refresh(warns on non-component exports)
Additional strictness rules enabled:
| Rule | Level | Effect |
|---|---|---|
eqeqeq |
error | Require === / !== |
no-var |
error | No var; use let or const |
prefer-const |
error | Use const when variable is never reassigned |
curly |
error | Require braces for all control structures |
@typescript-eslint/explicit-function-return-type |
warn | Explicit return types on functions |
@typescript-eslint/no-explicit-any |
error | No any type |
@typescript-eslint/strict-boolean-expressions |
error | No unintended type coercions in conditions |
@typescript-eslint/no-unsafe-assignment |
error | No unsafe assignments |
@typescript-eslint/prefer-nullish-coalescing |
error | Use ?? instead of || where appropriate |
@typescript-eslint/prefer-optional-chain |
error | Use ?. instead of chained && checks |
vite.config.ts has a separate override block that uses tsconfig.node.json and globals.node instead of globals.browser.
File: .prettierrc
{
"trailingComma": "none",
"semi": false
}All other options use Prettier's defaults: double quotes, 80-character print width, 2-space indentation.
.prettierignore excludes: build/, coverage/, node_modules/, package-lock.json, public/.
The application is deployed to GitHub Pages at:
https://<your-github>.github.io/grocery-list/
- Run
npm run buildto produce a production bundle indist/. - Run
npm run deployto pushdist/to thegh-pagesbranch using thegh-pagesnpm package. - GitHub Pages serves the
gh-pagesbranch automatically.
The base: "/grocery-list/" setting in vite.config.ts ensures all asset paths are prefixed correctly for the sub-path.
npm run deployThis runs deploy-before (which runs build) first, then pushes to gh-pages.
If you deploy to the root of a domain (e.g. https://mysite.com/) change base in vite.config.ts from "/grocery-list/" to "/", rebuild, and deploy your dist/ folder to your web server.
The app includes a basic Progressive Web App setup:
| File | Purpose |
|---|---|
public/manifest.json |
App name, icon definitions, theme colour, display mode |
public/favicon.ico |
Browser tab icon (16x16 up to 64x64) |
public/apple-touch-icon.png |
iOS home screen icon |
public/android-chrome-192x192.png |
Android home screen icon (192 px) |
public/android-chrome-512x512.png |
Android splash / large icon (512 px) |
manifest.json settings:
| Property | Value |
|---|---|
name |
"Grocery List" |
short_name |
"Grocery List" |
start_url |
/grocery-list/ |
display |
standalone (no browser chrome) |
theme_color |
#18181b (dark zinc) |
background_color |
#18181b |
To install the app on a mobile device, open the live URL in Chrome or Safari and use "Add to Home Screen". The app will launch in standalone mode (no address bar).
Note: This project does not include a Service Worker, so the PWA does not support offline mode.
Pages build and deployment triggers on every push, building and publishing the site to GitHub Pages. Status is shown by the badge at the top of this README.
CodeQL provides static security analysis, scanning for known vulnerability patterns in the JavaScript/TypeScript source. Status is shown by the badge at the top of this README.
Dependabot auto-merge (.github/workflows/automerge.yml):
| Condition | Action |
|---|---|
| Patch or minor update | Auto-approved and squash-merged |
| Major update | PR is auto-closed with a label |
update-type is null |
PR is auto-closed with a label |
File: .github/dependabot.yml
Dependabot runs on a monthly schedule for two ecosystems:
npmchecks all Node.js dependencies. Major version updates are ignored and require manual review.github-actionschecks all Actions versions used in workflows.
The project targets modern evergreen browsers (Chrome, Firefox, Edge, Safari). The combination of Vite's ES module output, Autoprefixer, and the ES2020 TypeScript target means:
- No Internet Explorer support.
- No polyfills are shipped.
localStorageis used for persistence and is supported by all modern browsers.crypto.randomUUID()is used for ID generation. This sets the effective minimum versions:
| Browser | Minimum version | Release date |
|---|---|---|
| Chrome | 92 | July 2021 |
| Edge | 92 | July 2021 |
| Firefox | 95 | December 2021 |
| Safari | 15.4 | March 2022 |
This project is licensed under the MIT License. See LICENSE for the full text.
- Background images are courtesy of Picsum Photos, a free open-source random image service.
- Alert dialogs powered by SweetAlert2.
- Edit and delete icons are inline SVGs sourced from the Font Awesome 5 (
fa) set via React Icons. - Scaffolded from the official Vite React-TS template.
- Thanks to all contributors and Dependabot for keeping dependencies current.
| Resource | URL |
|---|---|
| Live app | https://milliorn.github.io/grocery-list/ |
| React | https://react.dev/ |
| Vite | https://vite.dev/ |
| TypeScript | https://www.typescriptlang.org/ |
| Tailwind CSS | https://tailwindcss.com/ |
| SweetAlert2 | https://sweetalert2.github.io/ |
| Picsum Photos | https://picsum.photos/ |
| gh-pages | https://github.com/tschaub/gh-pages |
