improve on responsiveness with the use of react.memo

This commit is contained in:
Nico 2026-01-22 00:12:36 -08:00
parent 8d5b2d3ea3
commit ce2574c454
2 changed files with 118 additions and 84 deletions

View File

@ -1,9 +1,9 @@
import { useRef, useState } from "react"; import { memo, useRef, useState } from "react";
import AddImageModal from "../modals/AddImageModal"; import AddImageModal from "../modals/AddImageModal";
import ConfirmBuyModal from "../modals/ConfirmBuyModal"; import ConfirmBuyModal from "../modals/ConfirmBuyModal";
import ImageModal from "../modals/ImageModal"; 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 [showModal, setShowModal] = useState(false);
const [showAddImageModal, setShowAddImageModal] = useState(false); const [showAddImageModal, setShowAddImageModal] = useState(false);
const [showConfirmBuyModal, setShowConfirmBuyModal] = 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
);
});

View File

@ -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 { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
import FloatingActionButton from "../components/common/FloatingActionButton"; import FloatingActionButton from "../components/common/FloatingActionButton";
import SortDropdown from "../components/common/SortDropdown"; import SortDropdown from "../components/common/SortDropdown";
@ -18,7 +18,6 @@ export default function GroceryList() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
const [sortedItems, setSortedItems] = useState([]);
const [sortMode, setSortMode] = useState("zone"); const [sortMode, setSortMode] = useState("zone");
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [showAddForm, setShowAddForm] = useState(true); const [showAddForm, setShowAddForm] = useState(true);
@ -54,8 +53,8 @@ export default function GroceryList() {
loadRecentlyBought(); loadRecentlyBought();
}, []); }, []);
useEffect(() => { const sortedItems = useMemo(() => {
let sorted = [...items]; const sorted = [...items];
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); 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)); 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 === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
if (sortMode === "zone") { if (sortMode === "zone") {
sorted.sort((a, b) => { 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 -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); 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 || ""); const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
if (typeCompare !== 0) return typeCompare; if (typeCompare !== 0) return typeCompare;
// Then by item_group
const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
if (groupCompare !== 0) return groupCompare; if (groupCompare !== 0) return groupCompare;
// Then by zone
const zoneCompare = (a.zone || "").localeCompare(b.zone || ""); const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
if (zoneCompare !== 0) return zoneCompare; if (zoneCompare !== 0) return zoneCompare;
// Finally by name
return a.item_name.localeCompare(b.item_name); return a.item_name.localeCompare(b.item_name);
}); });
} }
setSortedItems(sorted); return sorted;
}, [items, sortMode]); }, [items, sortMode]);
const handleSuggest = async (text) => { const handleSuggest = async (text) => {
@ -95,10 +89,7 @@ export default function GroceryList() {
return; return;
} }
// Combine both unbought and recently bought items for similarity checking
const allItems = [...items, ...recentlyBoughtItems]; const allItems = [...items, ...recentlyBoughtItems];
// Check if exact match exists (case-insensitive)
const lowerText = text.toLowerCase().trim(); const lowerText = text.toLowerCase().trim();
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText); 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; if (!itemName.trim()) return;
const lowerItemName = itemName.toLowerCase().trim(); const lowerItemName = itemName.toLowerCase().trim();
// First check if exact item exists in database (case-insensitive)
let existingItem = null; let existingItem = null;
try { try {
const response = await getItemByName(itemName); const response = await getItemByName(itemName);
@ -131,29 +121,26 @@ export default function GroceryList() {
existingItem = null; existingItem = null;
} }
// If exact item exists, skip similarity check and process directly
if (existingItem) { if (existingItem) {
await processItemAddition(itemName, quantity); await processItemAddition(itemName, quantity);
return; return;
} }
// Only check for similar items if exact item doesn't exist setItems(prevItems => {
const allItems = [...items, ...recentlyBoughtItems]; const allItems = [...prevItems, ...recentlyBoughtItems];
const similar = findSimilarItems(itemName, allItems, 70); const similar = findSimilarItems(itemName, allItems, 70);
if (similar.length > 0) { if (similar.length > 0) {
// Show modal and wait for user decision setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity }); setShowSimilarModal(true);
setShowSimilarModal(true); return prevItems;
return; }
}
// Continue with normal flow for new items processItemAddition(itemName, quantity);
await processItemAddition(itemName, quantity); return prevItems;
}; });
}, [recentlyBoughtItems]);
const processItemAddition = async (itemName, quantity) => { const processItemAddition = useCallback(async (itemName, quantity) => {
// Check if item exists in database (case-insensitive)
let existingItem = null; let existingItem = null;
try { try {
const response = await getItemByName(itemName); const response = await getItemByName(itemName);
@ -163,7 +150,6 @@ export default function GroceryList() {
} }
if (existingItem && existingItem.bought === false) { if (existingItem && existingItem.bought === false) {
// Item exists and is unbought - update quantity
const currentQuantity = existingItem.quantity; const currentQuantity = existingItem.quantity;
const newQuantity = currentQuantity + quantity; const newQuantity = currentQuantity + quantity;
const yes = window.confirm( const yes = window.confirm(
@ -171,117 +157,140 @@ export default function GroceryList() {
); );
if (!yes) return; if (!yes) return;
await addItem(itemName, newQuantity, null); const response = await addItem(itemName, newQuantity, null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
if (response.data) {
setItems(prevItems =>
prevItems.map(item =>
item.id === existingItem.id ? { ...item, ...response.data } : item
)
);
}
} else if (existingItem) { } else if (existingItem) {
// Item exists in database (was previously bought) - just add quantity const response = await addItem(itemName, quantity, null);
await addItem(itemName, quantity, null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
if (response.data) {
setItems(prevItems => [...prevItems, response.data]);
setRecentlyBoughtItems(prevItems => prevItems.filter(item => item.id !== existingItem.id));
}
} else { } else {
// NEW ITEM - show combined add details modal
setPendingItem({ itemName, quantity }); setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true); setShowAddDetailsModal(true);
} }
}; }, []);
const handleSimilarCancel = () => { const handleSimilarCancel = useCallback(() => {
setShowSimilarModal(false); setShowSimilarModal(false);
setSimilarItemSuggestion(null); setSimilarItemSuggestion(null);
}; }, []);
const handleSimilarNo = async () => { const handleSimilarNo = useCallback(async () => {
if (!similarItemSuggestion) return; if (!similarItemSuggestion) return;
setShowSimilarModal(false); setShowSimilarModal(false);
// Create new item with original name
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity); await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null); setSimilarItemSuggestion(null);
}; }, [similarItemSuggestion, processItemAddition]);
const handleSimilarYes = async () => { const handleSimilarYes = useCallback(async () => {
if (!similarItemSuggestion) return; if (!similarItemSuggestion) return;
setShowSimilarModal(false); setShowSimilarModal(false);
// Use suggested item name
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity); await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null); setSimilarItemSuggestion(null);
}; }, [similarItemSuggestion, processItemAddition]);
const handleAddDetailsConfirm = async (imageFile, classification) => { const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
if (!pendingItem) return; if (!pendingItem) return;
try { try {
// Add item to grocery_list with image const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile); let newItem = addResponse.data;
// If classification provided, add it
if (classification) { if (classification) {
const itemResponse = await getItemByName(pendingItem.itemName); const itemResponse = await getItemByName(pendingItem.itemName);
const itemId = itemResponse.data.id; 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); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
}
} catch (error) { } catch (error) {
console.error("Failed to add item:", error); console.error("Failed to add item:", error);
alert("Failed to add item. Please try again."); alert("Failed to add item. Please try again.");
} }
}; }, [pendingItem]);
const handleAddDetailsSkip = async () => { const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return; if (!pendingItem) return;
try { try {
// Add item without image or classification const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
loadItems();
if (response.data) {
setItems(prevItems => [...prevItems, response.data]);
}
} catch (error) { } catch (error) {
console.error("Failed to add item:", error); console.error("Failed to add item:", error);
alert("Failed to add item. Please try again."); alert("Failed to add item. Please try again.");
} }
}; }, []);
const handleAddDetailsCancel = () => { const handleAddDetailsCancel = useCallback(() => {
setShowAddDetailsModal(false); setShowAddDetailsModal(false);
setPendingItem(null); setPendingItem(null);
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
}; }, []);
const handleBought = async (id, quantity) => { const handleBought = useCallback(async (id, quantity) => {
await markBought(id); 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 { try {
await updateItemImage(id, itemName, quantity, imageFile); const response = await updateItemImage(id, itemName, quantity, imageFile);
loadItems(); // Reload to show new image
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) { } catch (error) {
console.error("Failed to add image:", error); console.error("Failed to add image:", error);
alert("Failed to add image. Please try again."); 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; if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
try { try {
// Fetch existing classification
const classificationResponse = await getClassification(item.id); const classificationResponse = await getClassification(item.id);
setEditingItem({ setEditingItem({
...item, ...item,
@ -293,27 +302,37 @@ export default function GroceryList() {
setEditingItem({ ...item, classification: null }); setEditingItem({ ...item, classification: null });
setShowEditModal(true); setShowEditModal(true);
} }
}; }, [role]);
const handleEditSave = async (id, itemName, quantity, classification) => { const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
try { try {
await updateItemWithClassification(id, itemName, quantity, classification); const response = await updateItemWithClassification(id, itemName, quantity, classification);
setShowEditModal(false); setShowEditModal(false);
setEditingItem(null); 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) { } catch (error) {
console.error("Failed to update item:", error); console.error("Failed to update item:", error);
throw error; // Re-throw to let modal handle it throw error; // Re-throw to let modal handle it
} }
}; }, []);
const handleEditCancel = () => { const handleEditCancel = useCallback(() => {
setShowEditModal(false); setShowEditModal(false);
setEditingItem(null); setEditingItem(null);
}; }, []);
// Group items by zone for classification view
const groupItemsByZone = (items) => { const groupItemsByZone = (items) => {
const groups = {}; const groups = {};
items.forEach(item => { items.forEach(item => {
@ -346,7 +365,6 @@ export default function GroceryList() {
<SortDropdown value={sortMode} onChange={setSortMode} /> <SortDropdown value={sortMode} onChange={setSortMode} />
{sortMode === "zone" ? ( {sortMode === "zone" ? (
// Grouped view by zone
(() => { (() => {
const grouped = groupItemsByZone(sortedItems); const grouped = groupItemsByZone(sortedItems);
return Object.keys(grouped).map(zone => ( return Object.keys(grouped).map(zone => (
@ -375,7 +393,6 @@ export default function GroceryList() {
)); ));
})() })()
) : ( ) : (
// Regular flat list view
<ul className="glist-ul"> <ul className="glist-ul">
{sortedItems.map((item) => ( {sortedItems.map((item) => (
<GroceryListItem <GroceryListItem