DEV Community

OlumideSamuel
OlumideSamuel

Posted on

Shopping List - A Must Know React Javascript problem for any Frontend Interview (Part 2)

This is the React-JS implementation of the Shopping List problem. Link to the Vanilla-JS implementation will be below.

Problem

Create a shopping list application that allows a user to search for an item, add items, check them off, and delete them

As the user starts typing, it should hit the endpoint and show a list of partial matches. Clicking an item should add it to the list.

Requirements

  • Entering more than two characters in the input should show a list of partially matching items (starting with the same characters)
  • Clicking an item in the list of partially matching items should add it to the list
  • Adding the same item multiple times is allowed
  • Pressing the 'X' next to an item should delete it from the list
  • Pressing the '✓' next to an item should check it off (i.e. strikethrough text and partially grey out text/buttons)
  • Pressing the '✓' next to a checked-off item should uncheck it again

shopping-list-info

shopping-list-info2


Key Skills to be tested

  • Debounce Knowledge
  • Rendering lists with Unique Items
  • Filtering list to remove item
  • Marking item on list as complete.

Walkthrough...

Solution

First, we create an input field with the handleChange function to update the userInput state.

...
  const [userInput, setUserInput] = useState("");

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserInput(e.target.value);
  };

...
<div>
          <label htmlFor="search" id="search-label">
            Search:
          </label>
          <input
            id="searchfield"
            type="text"
            onChange={handleChange}
            value={userInput}
            placeholder="Search foods here"
          />
        </div>
Enter fullscreen mode Exit fullscreen mode

When the userInput updates, we must implement the debounce feature on the search field to prevent unnecessary network requests.

Debounce is a technique in javascript that delays network request by few microseconds to prevent unncessary network call.

Without debounce,
without-debounce

With debounce,
with debounce

Debounce

Debounce is a technique where we use setTimeout to delay the network request for a short while. If while it is being delayed, another network request is made by typing, it cancels the previous request using clearTimeout, and makes a new network request with the most recent typed values.

Basically, the debounce function cancels old requests while the user is still typing.

For the sake of simplicity, we handle this in a useEffect.

On Successful fetch of data from the API, we update the local state availableFoods with the result.

...
// In Parent App.tsx
  const [userInput, setUserInput] = useState("");
  const [availableFoods, setAvailableFoods] = useState([]);
  const [results, setResults] = useState<IResults[]>([]);
  const [isSearchVisible, setIsSearchVisible] = useState(true);

...
useEffect(() => {
    let timer: number | undefined;
    if (userInput) {
      timer = setTimeout(async () => {
        clearTimeout(timer);
        const result = await onFoodSearch();
        setAvailableFoods(result);
        setIsSearchVisible(true);
      }, 1000);
    }

    return () => {
      clearTimeout(timer);
    };
  }, [userInput]);

const onFoodSearch = async () => {
  try {
    const result = fetch(
      `https://api.frontendeval.com/fake/food/${userInput}`
    ).then((res) => res.json());
    return result;
  } catch (error) {
    console.log(error);
    return [];
  }
};
...
Enter fullscreen mode Exit fullscreen mode

For a cute approach, this can also be extracted into a hook separate hook function that accepts the request and delay as arguments.

Rendering Search Results

When we have a list of search results, we render the list and add an onClick event
that triggers the handleItemSelect function.

When a user selects an item on the list, before adding to the results lists,

  • we generate a random number between 1 and 100 as id (ideally, we use a guuid or a more unique representation as id for each item)
  • set the isCompleted status to false
  • add the item to the results
...
export interface IResults {
  id: string;
  item: string;
  isCompleted: boolean;
}
...
// In Parent App.tsx
const [results, setResults] = useState<IResults[]>([]);

 ...
const handleItemSelect = (item: string) => {
  const idx = Math.floor(Math.random() * 100 + 1);
  const resObj = { id: `${idx}-${item}`, item, isCompleted: false };
  setResults((prev) => [...prev, resObj]);
};

  ...

<div className="options">
  {availableFoods.length > 0 && (
    <SearchResults
      foodLists={availableFoods}
      onSelect={handleItemSelect}
      ref={ref}
    />
  )}
</div>

...
...
// In SearchResults.tsx
type SearchResultsProp = {
  foodLists: string[];
  onSelect: (item: string) => void;
};
export const SearchResults = forwardRef<HTMLDivElement, SearchResultsProp>(
  ({ foodLists = [], onSelect }: SearchResultsProp, ref) => {
    return (
      <div id="search-results" ref={ref}>
        {foodLists.map((item) => (
          <SearchItem item={item} key={item} onSelect={onSelect} />
        ))}
      </div>
    );
  }
);

Enter fullscreen mode Exit fullscreen mode

Rendering the results

The results are rendered with the key action events attached to each item

...
// In Parent App.tsx
<div id="results">
  <h3>Results</h3>
  {results.map((item, index) => (
    <FoodItem
      item={item}
      idx={index}
      key={index}
      onDelete={handleDeleteItem}
      onCheckClick={handleCheckClick}
    />
  ))}
</div>


...
// In FoodItem.tsx
type FoodItemProp = {
  item: IResults;
  onDelete: (item: IResults) => void;
  onCheckClick: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
export const FoodItem = ({ item, onDelete, onCheckClick }: FoodItemProp) => {
  return (
    <div className={`list-item ${item.isCompleted ? "complete" : ""}`}>
      <div className="info">
        <input type="checkbox" name={item.id} onChange={onCheckClick} />
        <p className={item.isCompleted ? "strike" : ""}>{item.item}</p>
      </div>

      <button onClick={() => onDelete(item)} className="del-btn">
        X
      </button>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

Deleting and Marking Item as Complete

  • To delete an item from the list, we use the set id to filter out item from the list and update the results with the new list
...
  const handleDeleteItem = (item: IResults) => {
    const filteredRes = results.filter((food) => food.id !== item.id);
    setResults(filteredRes);
  };
...
Enter fullscreen mode Exit fullscreen mode
  • To mark an item on the list as complete, loop through the results list to find the item with the same id as the selected item, and update the isCompleted property. It can be done with a one-liner code or more explicitly as shown below.
...
const handleCheckClick = (e: React.ChangeEvent<HTMLInputElement>) => {
  const isChecked = e.target?.checked;
  const id = e.target.name;

  if (isChecked) {
    //When a User marks as complete. 
    // Explicit update
    const temp = results.map((item) => {
      if (item.id === id) {
        return { ...item, isCompleted: true };
      } else return item;
    });
    setResults(temp);
  } else {
    //When a user marks as incomplete.
    // One-liner
    const temp = results.map((item) =>
      item.id === id ? { ...item, isCompleted: false } : item
    );
    setResults(temp);
  }
};
...
Enter fullscreen mode Exit fullscreen mode

Hide Search Lists container

To hide the Search Results container when a user clicks outside the input area, we add a click listener to the global document and a ref to the search results container.

On click on the document, if the search results container does not contain an instance of the global document, set its visibility to false.

...
  const ref = useRef<HTMLDivElement>(null);
...
useEffect(() => {
const handleOutsideClick = (event: MouseEvent) => {
  // @ts-expect-error event ignore
  if (ref.current && !ref.current?.contains(event.target)) {
      setIsSearchVisible(false);
    }
  };

  document.addEventListener("mousedown", handleOutsideClick);

  return () => {
    document.removeEventListener("mousedown", handleOutsideClick);
  };
}, []);

...
<div className="options">
  {isSearchVisible && availableFoods.length > 0 && (
    <SearchResults
      foodLists={availableFoods}
      onSelect={handleItemSelect}
      ref={ref}
    />
  )}
</div>
        ...

Enter fullscreen mode Exit fullscreen mode

Link to code on Github.

Link to Vanilla-JS solution walkthrough here

The final look here

Image description

Leave a comment if you have any questions. Like and share if this is helpful to you in some way 🙂

Top comments (0)