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
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>
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.
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 [];
}
};
...
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>
);
}
);
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>
);
};
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 theresults
with the new list
...
const handleDeleteItem = (item: IResults) => {
const filteredRes = results.filter((food) => food.id !== item.id);
setResults(filteredRes);
};
...
- 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);
}
};
...
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>
...
Link to code on Github.
Link to Vanilla-JS solution walkthrough here
The final look here
Leave a comment if you have any questions. Like and share if this is helpful to you in some way 🙂
Top comments (0)