DEV Community

Rita Bradley
Rita Bradley

Posted on

Build a Summer-Ready Packing List App with React and TailwindCSS

Hey there, lovely people! ๐ŸŒž

It's summer in the United States, which means it's the season for weekend getaways, trips to the beach, or maybe just a day off lounging at home pretending you're in Bali. Whether you're a hardcore planner or a last-minute packer, we've all been through the struggle of forgetting something essential on a trip, haven't we?

To save us all from the โ€œOh no, I forgot my _____!โ€ moment, I built a packing list app using React and TailwindCSS. You can check it out here and see a live demo here. This post will walk you through how I built it.

Screenshot of AFK Packing List web app

Getting Started with the Project

I used Vite with the React template to scaffold the project. This was as simple as running the following command in my terminal:

npm create vite@latest my-vue-app --template react
Enter fullscreen mode Exit fullscreen mode

I then used TailwindCSS for the styling because, well, who doesn't love utility-first CSS frameworks, am I right?

Configuring TailwindCSS

I customized the default Tailwind configuration (tailwind.config.js) to add some extra colors and fonts that I wanted to use throughout the app. These colors and fonts help give the app a little nautical โš“๏ธ personality, and who doesn't love that? Here's a peek at my Tailwind config:

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        whip: '#fdf0d5',
        maroon: '#780000',
        lava: '#c1121f',
        navy: '#003049',
        cerulean: '#669bbc',
      },
      fontFamily: {
        body: ['"Karla"', 'sans-serif'],
        heading: ['"Caprasimo"', 'cursive'],
      },
    },
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Adding custom properties to the extend field in the theme section lets you extend Tailwind's default config rather than completely replacing it.

Styling with TailwindCSS

Next, let's dive into how I styled the app. The majority of the styling is defined in index.css and App.css. I used the @apply directive from Tailwind to apply multiple utility classes to my own custom class, and used @layer to ensure that these styles are included in the right place in the final CSS file.

The index.css file:

@import url('https://fonts.googleapis.com/css2?family=Caprasimo&family=Karla:wght@500;700&display=swap');

@tailwind base;
@tailwind components;
@tailwind utilities;

.list ul {
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}

@layer components {
  .pill {
    @apply bg-whip text-navy font-body px-8 py-3 text-lg font-bold border-none rounded-full cursor-pointer;
  }

  .pill-sm {
    @apply px-6 py-2 mx-2 text-sm font-bold uppercase;
  }
}
Enter fullscreen mode Exit fullscreen mode

The App.css file simply defines the grid for the app:

.app {
  grid-template-rows: auto auto 1fr auto;
}
Enter fullscreen mode Exit fullscreen mode

Fun Fact: Did you know that with TailwindCSS, you can use the @apply directive to dry up your styles by reusing utility patterns?

Creating the Components

Our app is divided into six main components: App, Header, Form, PackingList, Item, and Stats.

Here's a quick rundown:

  • App: This is the root component of our application. It's where we manage all our state and handle all the logic for our app.
  • Header: Just a simple header to give our app a title.
  • Form: The input form where users can add items to their packing list.
  • PackingList: The heart of our app where we display all the items on the packing list.
  • Item: The individual items in the PackingList. Each item has its own checkbox and remove button for user interactivity.
  • Stats: A component to show some fun statistics about our packing progress.

Now, let's take a closer look at each component.

The App Component

import { useState } from 'react';
import Header from './components/Header';
import Form from './components/Form';
import PackingList from './components/PackingList';
import Stats from './components/Stats';

import './App.css';

function App() {
  const [items, setItems] = useState([]);
  const [description, setDescription] = useState('');
  const [quantity, setQuantity] = useState(1);

  function handleQuantityChange(e) {
    setQuantity(e.target.value);
  }

  function handleDescriptionChange(e) {
    setDescription(e.target.value);
  }

  function handleFormSubmit(e) {
    e.preventDefault();
    const newItem = {
      id: items.length + 1,
      inputOrder: items.length + 1,
      quantity: quantity,
      description: description,
      packed: false,
    };
    setItems([...items, newItem]);
    setQuantity(1);
    setDescription('');
  }

  // Handle packed state of each item
  function handlePackedChange(id) {
    // Create a new array with the same items, but with the packed state of the selected item toggled
    const newItems = items.map((item) => (item.id === id ? { ...item, packed: !item.packed } : item));
    setItems(newItems);
  }

  function handleRemoveItem(id) {
    const updatedItems = items.filter((item) => item.id !== id);
    setItems(updatedItems);
  }

  function calculatePackedItems() {
    return items.filter((item) => item.packed).length;
  }

  function calculatePercentagePacked() {
    return Math.round((calculatePackedItems() / items.length) * 100);
  }

  function handleSort(e) {
    const sortBy = e.target.value;
    setItems((currentItems) => {
      const sortedItems = [...currentItems];
      if (sortBy === 'input') {
        sortedItems.sort((a, b) => a.inputOrder - b.inputOrder);
        return sortedItems;
      } else if (sortBy === 'description') {
        sortedItems.sort((a, b) => {
          if (a.description.toLowerCase() < b.description.toLowerCase()) {
            return -1;
          } else if (a.description.toLowerCase() > b.description.toLowerCase()) {
            return 1;
          } else {
            return 0;
          }
        });
        return sortedItems;
      } else if (sortBy === 'packed') {
        sortedItems.sort((a, b) => {
          if (a.packed < b.packed) {
            return -1;
          } else if (a.packed > b.packed) {
            return 1;
          } else {
            return 0;
          }
        });
        return sortedItems;
      }
    });
  }

  function handleClearList() {
    setItems([]);
  }

  const numOfItems = items.length;
  const numOfPacked = calculatePackedItems();
  const percentPacked = numOfItems > 0 ? calculatePercentagePacked() : 0;

  return (
    <div className='app font-body text-navy grid w-full h-screen'>
      <Header />
      <Form
        onFormSubmit={handleFormSubmit}
        quantity={quantity}
        description={description}
        onDescriptionChange={handleDescriptionChange}
        onQuantityChange={handleQuantityChange}
      />
      <PackingList
        items={items}
        onPackedChange={handlePackedChange}
        onRemoveItem={handleRemoveItem}
        onClearList={handleClearList}
        onSort={handleSort}
      />
      <Stats numOfItems={numOfItems} numOfPacked={numOfPacked} percentPacked={percentPacked} />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

This is the main component of our application. We use React's useState hook to keep track of the list items and the current item quantity and description. There are several helper functions for managing the packing list:

  • handleQuantityChange(e): This function updates the quantity value in the state whenever the user changes the quantity input field.

  • handleDescriptionChange(e): This function updates the description value in the state whenever the user changes the description input field.

  • handleFormSubmit(e): When the user submits the form, this function prevents the page from reloading, creates a new item object, adds this object to the current items list, and resets the quantity and description fields for further input.

  • handlePackedChange(id): This function toggles the packed status of a specific item when the user checks or unchecks the associated checkbox.

  • handleRemoveItem(id): If a user clicks the remove button on a specific item, this function removes that item from the items list.

  • calculatePackedItems(): This function calculates the number of packed items in the list.

  • calculatePercentagePacked(): This function calculates the percentage of items that are packed.

  • handleSort(e): This function sorts the list of items based on the user's preference: by input order, by description, or by packed status.

  • handleClearList(): This function clears all items from the list when the user clicks the "Clear List" button.

The Header Component

Header.jsx is the simplest of our components. It only returns a styled h1 tag, and there are no props or state involved. Easy peasy! ๐Ÿ‹

export default function Header() {
  return (
    <h1 className='font-heading bg-cerulean py-6 text-6xl tracking-tight text-center uppercase'>
      โœˆ๏ธ AFK Packing List โŒจ๏ธ
    </h1>
  );
}

Enter fullscreen mode Exit fullscreen mode

The Form Component

The Form component handles user inputs for new items and their quantities. It takes the current quantity and description from the App component via props and uses these to pre-fill the form fields.

export default function Form({ onFormSubmit, quantity, description, onQuantityChange, onDescriptionChange }) {
  return (
    <form className='bg-lava py-7 flex items-center justify-center gap-3' onSubmit={onFormSubmit}>
      <h3 className='mr-4 text-2xl'>what do you need for your trip?</h3>
      <select value={quantity} onChange={onQuantityChange} id='quantity' className='pill focus:outline-lava'>
        {[...Array(20)].map((_, i) => (
          <option key={i} value={i + 1}>
            {i + 1}
          </option>
        ))}
      </select>
      <input
        value={description}
        onChange={onDescriptionChange}
        id='description'
        className='pill focus:outline-lava'
        type='text'
        placeholder='Item...'
      />
      <button className='pill bg-cerulean focus:outline-navy uppercase' type='submit'>
        Add
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component also uses the onFormSubmit, onQuantityChange, and onDescriptionChange props to handle form submissions and updates to the quantity and description fields.

The select input for quantity is a neat little feature! ๐ŸŽฉ The quantity input leverages JavaScript's array and map methods to create a drop-down menu with 20 options, allowing the user to easily select the quantity of an item.

[...Array(20)].map((_, i) => (
  <option key={i} value={i + 1}>
    {i + 1}
  </option>
))
Enter fullscreen mode Exit fullscreen mode
  1. First, we create an array with 20 empty items by using the Array constructor and the ES6 array spread operator.
  2. Then, we call the map() method to iterate over the array and create a new array with the number of options we need.
  3. The map() method calls the callback function on each array item.
  4. The value of the first argument is always the current array item.
  5. The value of the second argument is always the index of the current item.
  6. We use the index to generate a unique key for each option. Each Item also has a checkbox and a remove button. The checkbox allows the user to toggle the packed status of an item, and the remove button allows the user to remove an item from the list.

The Packing List Component

This is where the magic happens! โœจ The PackingList component takes the array of items and maps over them, creating a new Item component for each.

import Item from './Item';

export default function PackingList({ items, onPackedChange, onRemoveItem, onClearList, onSort }) {
  return (
    <section className='bg-navy text-whip list flex flex-col items-center justify-between gap-8 py-10'>
      <ul className='grid content-start justify-center w-4/5 gap-3 overflow-scroll list-none'>
        {items.map((item) => {
          return (
            <Item
              key={item.id}
              id={item.id}
              description={item.description}
              quantity={item.quantity}
              packed={item.packed}
              onPackedChange={onPackedChange}
              onRemoveItem={onRemoveItem}
            />
          );
        })}
      </ul>
      <div>
        <select onChange={onSort} className='pill pill-sm'>
          <option value='input'>Sort by input order</option>
          <option value='description'>Sort by description</option>
          <option value='packed'>Sort by packed status</option>
        </select>
        <button onClick={onClearList} className='pill pill-sm'>
          Clear List
        </button>
      </div>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Additionally, this component includes controls for sorting the list and clearing all items, which makes our packing list not only functional, but also user-friendly!

The Item Component

Now, let's take a look at the Item component. Each Item represents an individual item on the packing list and is responsible for rendering the item's quantity, description, and packed status.

export default function Item({ id, description, quantity, packed, onPackedChange, onRemoveItem }) {
  return (
    <>
      <li className='flex items-center gap-3'>
        <input
          className='accent-cerulean w-5 h-5'
          type='checkbox'
          checked={packed}
          onChange={() => onPackedChange(id)}
        />
        <span style={packed ? { textDecoration: 'line-through' } : {}}>
          {quantity} {description}
        </span>
        <button
          className='bg-none p-2 text-lg translate-y-0.5 border-none cursor-pointer'
          onClick={() => onRemoveItem(id)}
        >
          โŒ
        </button>
      </li>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Stats Component

Last but not least, the Stats component. This component displays some statistics about our packing list, like the total number of items, the number of items already packed, and the percentage of items packed.

export default function Stats({ numOfItems = 0, numOfPacked = 0, percentPacked = 0 }) {
  return (
    <footer className='bg-maroon text-whip py-8 text-lg font-bold text-center'>
      <p>
        You have {numOfItems} items on your list and you&apos;ve already packed {numOfPacked} ({percentPacked}%)
      </p>
    </footer>
  );
}
Enter fullscreen mode Exit fullscreen mode

And there you have it โ€“ a summer-ready packing list app built with React and TailwindCSS! I hope you've enjoyed this little peek into my process, and I hope it's inspired you to create something of your own.

I hope this walkthrough helps you understand how I built the AFK Packing List. If you're interested in building your own version or contributing to mine, feel free to clone the repository and give it a go! ๐Ÿš€

Until next time, happy coding and happy travels! ๐Ÿš€

Takeaway: Building this packing list app not only helps keep your travels stress-free, but also gives you a practical and fun way to put your React and TailwindCSS knowledge into practice. What other projects have you built to solve everyday problems? Share in the comments below!

Top comments (0)