From ce2574c454a6188632a31daa08a46ecfe20fa218 Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 22 Jan 2026 00:12:36 -0800 Subject: [PATCH] improve on responsiveness with the use of react.memo --- .../src/components/items/GroceryListItem.jsx | 21 +- frontend/src/pages/GroceryList.jsx | 181 ++++++++++-------- 2 files changed, 118 insertions(+), 84 deletions(-) diff --git a/frontend/src/components/items/GroceryListItem.jsx b/frontend/src/components/items/GroceryListItem.jsx index d2417cb..0c506b7 100644 --- a/frontend/src/components/items/GroceryListItem.jsx +++ b/frontend/src/components/items/GroceryListItem.jsx @@ -1,9 +1,9 @@ -import { useRef, useState } from "react"; +import { memo, useRef, useState } from "react"; import AddImageModal from "../modals/AddImageModal"; import ConfirmBuyModal from "../modals/ConfirmBuyModal"; import ImageModal from "../modals/ImageModal"; -export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) { +function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) { const [showModal, setShowModal] = useState(false); const [showAddImageModal, setShowAddImageModal] = useState(false); const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false); @@ -176,3 +176,20 @@ export default function GroceryListItem({ item, onClick, onImageAdded, onLongPre ); } + +// Memoize component to prevent re-renders when props haven't changed +export default memo(GroceryListItem, (prevProps, nextProps) => { + // Only re-render if the item data or handlers have changed + return ( + prevProps.item.id === nextProps.item.id && + prevProps.item.item_name === nextProps.item.item_name && + prevProps.item.quantity === nextProps.item.quantity && + prevProps.item.item_image === nextProps.item.item_image && + prevProps.item.bought === nextProps.item.bought && + prevProps.item.last_added_on === nextProps.item.last_added_on && + prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') && + prevProps.onClick === nextProps.onClick && + prevProps.onImageAdded === nextProps.onImageAdded && + prevProps.onLongPress === nextProps.onLongPress + ); +}); diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index 296af46..c7b1975 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list"; import FloatingActionButton from "../components/common/FloatingActionButton"; import SortDropdown from "../components/common/SortDropdown"; @@ -18,7 +18,6 @@ export default function GroceryList() { const [items, setItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10); - const [sortedItems, setSortedItems] = useState([]); const [sortMode, setSortMode] = useState("zone"); const [suggestions, setSuggestions] = useState([]); const [showAddForm, setShowAddForm] = useState(true); @@ -54,8 +53,8 @@ export default function GroceryList() { loadRecentlyBought(); }, []); - useEffect(() => { - let sorted = [...items]; + const sortedItems = useMemo(() => { + const sorted = [...items]; if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); @@ -63,29 +62,24 @@ export default function GroceryList() { if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); if (sortMode === "zone") { sorted.sort((a, b) => { - // Items without classification go to the end if (!a.item_type && b.item_type) return 1; if (a.item_type && !b.item_type) return -1; if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name); - // Sort by item_type const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); if (typeCompare !== 0) return typeCompare; - // Then by item_group const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); if (groupCompare !== 0) return groupCompare; - // Then by zone const zoneCompare = (a.zone || "").localeCompare(b.zone || ""); if (zoneCompare !== 0) return zoneCompare; - // Finally by name return a.item_name.localeCompare(b.item_name); }); } - setSortedItems(sorted); + return sorted; }, [items, sortMode]); const handleSuggest = async (text) => { @@ -95,10 +89,7 @@ export default function GroceryList() { return; } - // Combine both unbought and recently bought items for similarity checking const allItems = [...items, ...recentlyBoughtItems]; - - // Check if exact match exists (case-insensitive) const lowerText = text.toLowerCase().trim(); const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText); @@ -117,12 +108,11 @@ export default function GroceryList() { } }; - const handleAdd = async (itemName, quantity) => { + const handleAdd = useCallback(async (itemName, quantity) => { if (!itemName.trim()) return; const lowerItemName = itemName.toLowerCase().trim(); - // First check if exact item exists in database (case-insensitive) let existingItem = null; try { const response = await getItemByName(itemName); @@ -131,29 +121,26 @@ export default function GroceryList() { existingItem = null; } - // If exact item exists, skip similarity check and process directly if (existingItem) { await processItemAddition(itemName, quantity); return; } - // Only check for similar items if exact item doesn't exist - const allItems = [...items, ...recentlyBoughtItems]; - const similar = findSimilarItems(itemName, allItems, 70); - if (similar.length > 0) { - // Show modal and wait for user decision - setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity }); - setShowSimilarModal(true); - return; - } + setItems(prevItems => { + const allItems = [...prevItems, ...recentlyBoughtItems]; + const similar = findSimilarItems(itemName, allItems, 70); + if (similar.length > 0) { + setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity }); + setShowSimilarModal(true); + return prevItems; + } - // Continue with normal flow for new items - await processItemAddition(itemName, quantity); - }; + processItemAddition(itemName, quantity); + return prevItems; + }); + }, [recentlyBoughtItems]); - const processItemAddition = async (itemName, quantity) => { - - // Check if item exists in database (case-insensitive) + const processItemAddition = useCallback(async (itemName, quantity) => { let existingItem = null; try { const response = await getItemByName(itemName); @@ -163,7 +150,6 @@ export default function GroceryList() { } if (existingItem && existingItem.bought === false) { - // Item exists and is unbought - update quantity const currentQuantity = existingItem.quantity; const newQuantity = currentQuantity + quantity; const yes = window.confirm( @@ -171,117 +157,140 @@ export default function GroceryList() { ); if (!yes) return; - await addItem(itemName, newQuantity, null); + const response = await addItem(itemName, newQuantity, null); setSuggestions([]); setButtonText("Add Item"); - loadItems(); + + if (response.data) { + setItems(prevItems => + prevItems.map(item => + item.id === existingItem.id ? { ...item, ...response.data } : item + ) + ); + } } else if (existingItem) { - // Item exists in database (was previously bought) - just add quantity - await addItem(itemName, quantity, null); + const response = await addItem(itemName, quantity, null); setSuggestions([]); setButtonText("Add Item"); - loadItems(); + + if (response.data) { + setItems(prevItems => [...prevItems, response.data]); + setRecentlyBoughtItems(prevItems => prevItems.filter(item => item.id !== existingItem.id)); + } } else { - // NEW ITEM - show combined add details modal setPendingItem({ itemName, quantity }); setShowAddDetailsModal(true); } - }; + }, []); - const handleSimilarCancel = () => { + const handleSimilarCancel = useCallback(() => { setShowSimilarModal(false); setSimilarItemSuggestion(null); - }; + }, []); - const handleSimilarNo = async () => { + const handleSimilarNo = useCallback(async () => { if (!similarItemSuggestion) return; setShowSimilarModal(false); - // Create new item with original name await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity); setSimilarItemSuggestion(null); - }; + }, [similarItemSuggestion, processItemAddition]); - const handleSimilarYes = async () => { + const handleSimilarYes = useCallback(async () => { if (!similarItemSuggestion) return; setShowSimilarModal(false); - // Use suggested item name await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity); setSimilarItemSuggestion(null); - }; + }, [similarItemSuggestion, processItemAddition]); - const handleAddDetailsConfirm = async (imageFile, classification) => { + const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => { if (!pendingItem) return; try { - // Add item to grocery_list with image - await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); + const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); + let newItem = addResponse.data; - // If classification provided, add it if (classification) { const itemResponse = await getItemByName(pendingItem.itemName); const itemId = itemResponse.data.id; - await updateItemWithClassification(itemId, undefined, undefined, classification); + const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification); + newItem = { ...newItem, ...updateResponse.data }; } setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); - loadItems(); + + if (newItem) { + setItems(prevItems => [...prevItems, newItem]); + } } catch (error) { console.error("Failed to add item:", error); alert("Failed to add item. Please try again."); } - }; + }, [pendingItem]); - const handleAddDetailsSkip = async () => { + const handleAddDetailsSkip = useCallback(async () => { if (!pendingItem) return; try { - // Add item without image or classification - await addItem(pendingItem.itemName, pendingItem.quantity, null); + const response = await addItem(pendingItem.itemName, pendingItem.quantity, null); setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); - loadItems(); + + if (response.data) { + setItems(prevItems => [...prevItems, response.data]); + } } catch (error) { console.error("Failed to add item:", error); alert("Failed to add item. Please try again."); } - }; + }, []); - const handleAddDetailsCancel = () => { + const handleAddDetailsCancel = useCallback(() => { setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); - }; + }, []); - const handleBought = async (id, quantity) => { + const handleBought = useCallback(async (id, quantity) => { await markBought(id); - loadItems(); - loadRecentlyBought(); - }; - const handleImageAdded = async (id, itemName, quantity, imageFile) => { + setItems(prevItems => prevItems.filter(item => item.id !== id)); + loadRecentlyBought(); + }, []); + + const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => { try { - await updateItemImage(id, itemName, quantity, imageFile); - loadItems(); // Reload to show new image + const response = await updateItemImage(id, itemName, quantity, imageFile); + + setItems(prevItems => + prevItems.map(item => + item.id === id ? { ...item, ...response.data } : item + ) + ); + + setRecentlyBoughtItems(prevItems => + prevItems.map(item => + item.id === id ? { ...item, ...response.data } : item + ) + ); } catch (error) { console.error("Failed to add image:", error); alert("Failed to add image. Please try again."); } - }; + }, []); - const handleLongPress = async (item) => { + const handleLongPress = useCallback(async (item) => { if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return; try { - // Fetch existing classification const classificationResponse = await getClassification(item.id); setEditingItem({ ...item, @@ -293,27 +302,37 @@ export default function GroceryList() { setEditingItem({ ...item, classification: null }); setShowEditModal(true); } - }; + }, [role]); - const handleEditSave = async (id, itemName, quantity, classification) => { + const handleEditSave = useCallback(async (id, itemName, quantity, classification) => { try { - await updateItemWithClassification(id, itemName, quantity, classification); + const response = await updateItemWithClassification(id, itemName, quantity, classification); setShowEditModal(false); setEditingItem(null); - loadItems(); - loadRecentlyBought(); + + const updatedItem = response.data; + setItems(prevItems => + prevItems.map(item => + item.id === id ? { ...item, ...updatedItem } : item + ) + ); + + setRecentlyBoughtItems(prevItems => + prevItems.map(item => + item.id === id ? { ...item, ...updatedItem } : item + ) + ); } catch (error) { console.error("Failed to update item:", error); throw error; // Re-throw to let modal handle it } - }; + }, []); - const handleEditCancel = () => { + const handleEditCancel = useCallback(() => { setShowEditModal(false); setEditingItem(null); - }; + }, []); - // Group items by zone for classification view const groupItemsByZone = (items) => { const groups = {}; items.forEach(item => { @@ -346,7 +365,6 @@ export default function GroceryList() { {sortMode === "zone" ? ( - // Grouped view by zone (() => { const grouped = groupItemsByZone(sortedItems); return Object.keys(grouped).map(zone => ( @@ -375,7 +393,6 @@ export default function GroceryList() { )); })() ) : ( - // Regular flat list view