List and Collection State Management
1. Array State Operations (CRUD) with Keys
| Operation | Immutable Pattern | Key Strategy | Performance Note |
|---|---|---|---|
| Create (Add) | [...items, newItem] or items.concat(newItem) |
Use unique ID (UUID, timestamp, server ID) | O(n) - spreads entire array |
| Read (Access) | items.find(i => i.id === id) |
Search by unique identifier | O(n) - consider normalized state for O(1) |
| Update (Modify) | items.map(i => i.id === id ? updated : i) |
Preserve key, update properties | O(n) - creates new array |
| Delete (Remove) | items.filter(i => i.id !== id) |
Remove by ID, not index | O(n) - creates new array |
| Reorder | Array splice or swap indices | Maintain stable keys during reorder | Use key for React reconciliation |
Example: Complete CRUD operations for todo list
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build app', completed: false }
]);
// CREATE - Add new todo
const addTodo = (text) => {
const newTodo = {
id: Date.now(), // or crypto.randomUUID() in modern browsers
text,
completed: false
};
setTodos(prev => [...prev, newTodo]); // Add to end
// or: setTodos(prev => [newTodo, ...prev]); // Add to beginning
};
// READ - Find specific todo
const getTodo = (id) => {
return todos.find(todo => todo.id === id);
};
// UPDATE - Modify todo
const updateTodo = (id, updates) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, ...updates }
: todo
)
);
};
// Toggle completed status
const toggleTodo = (id) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
// DELETE - Remove todo
const deleteTodo = (id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// DELETE ALL - Clear completed
const clearCompleted = () => {
setTodos(prev => prev.filter(todo => !todo.completed));
};
// BULK UPDATE - Mark all as completed
const completeAll = () => {
setTodos(prev =>
prev.map(todo => ({ ...todo, completed: true }))
);
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}> {/* CRITICAL: Use unique ID as key */}
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</div>
))}
<button onClick={() => addTodo('New task')}>Add Todo</button>
<button onClick={clearCompleted}>Clear Completed</button>
<button onClick={completeAll}>Complete All</button>
</div>
);
}
Key Anti-patterns:
- ❌
key={index}- Breaks when items reorder, add, or remove. React can't track identity. - ❌
key={Math.random()}- New key every render, forces remount, loses component state - ❌ Mutating array -
items.push(),items[i] = val- React won't detect change - ✅
key={item.id}- Stable, unique identifier from data - ✅
key={crypto.randomUUID()}- Only when creating new item, stored in state
2. Filtering and Sorting State Implementation
| Pattern | State Structure | Derived Calculation | Performance |
|---|---|---|---|
| Filter state | {items, filter: 'all'} |
items.filter(matchesFilter) |
Recalculate on render (fast for small lists) |
| Sort state | {items, sortBy: 'name', sortDir: 'asc'} |
items.sort(compareFn) |
⚠️ Don't mutate, use .slice().sort() |
| Combined filter + sort | {items, filter, sortBy, sortDir} |
Filter first, then sort | Use useMemo for expensive operations |
| Memoized derived state | Same structure | useMemo(() => filter + sort, deps) |
Only recalculates when dependencies change |
Example: Filtering and sorting implementation
function ProductList() {
const [products] = useState([
{ id: 1, name: 'Laptop', category: 'Electronics', price: 999 },
{ id: 2, name: 'Desk', category: 'Furniture', price: 299 },
{ id: 3, name: 'Mouse', category: 'Electronics', price: 29 },
{ id: 4, name: 'Chair', category: 'Furniture', price: 199 }
]);
const [filter, setFilter] = useState('all'); // 'all', 'Electronics', 'Furniture'
const [sortBy, setSortBy] = useState('name'); // 'name', 'price'
const [sortDir, setSortDir] = useState('asc'); // 'asc', 'desc'
// Derived state - filter and sort
const filteredAndSorted = useMemo(() => {
console.log('Recalculating filtered and sorted products');
// Step 1: Filter
let result = products;
if (filter !== 'all') {
result = result.filter(p => p.category === filter);
}
// Step 2: Sort (don't mutate - use slice())
result = result.slice().sort((a, b) => {
let comparison = 0;
if (sortBy === 'name') {
comparison = a.name.localeCompare(b.name);
} else if (sortBy === 'price') {
comparison = a.price - b.price;
}
return sortDir === 'asc' ? comparison : -comparison;
});
return result;
}, [products, filter, sortBy, sortDir]);
const toggleSort = (field) => {
if (sortBy === field) {
// Same field - toggle direction
setSortDir(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
// New field - default to ascending
setSortBy(field);
setSortDir('asc');
}
};
return (
<div>
{/* Filter controls */}
<div>
<button onClick={() => setFilter('all')}>All</button>
<button onClick={() => setFilter('Electronics')}>Electronics</button>
<button onClick={() => setFilter('Furniture')}>Furniture</button>
</div>
{/* Sort controls */}
<div>
<button onClick={() => toggleSort('name')}>
Sort by Name {sortBy === 'name' && (sortDir === 'asc' ? '▲' : '▼')}
</button>
<button onClick={() => toggleSort('price')}>
Sort by Price {sortBy === 'price' && (sortDir === 'asc' ? '▲' : '▼')}
</button>
</div>
{/* Display filtered and sorted results */}
<div>
{filteredAndSorted.map(product => (
<div key={product.id}>
{product.name} - ${product.price} ({product.category})
</div>
))}
</div>
</div>
);
}
Sorting Gotcha: Array's
.sort() method mutates the array! Always use
.slice().sort() or [...array].sort() to avoid mutating state. For large datasets
(>1000 items), consider server-side filtering/sorting or virtualization.
3. Pagination State and Virtual Scrolling
| Pattern | State Structure | Calculation | Best For |
|---|---|---|---|
| Page-based pagination | {page: 1, pageSize: 10} |
items.slice(start, end) |
Traditional pagination with page numbers |
| Cursor-based pagination | {cursor: null, hasMore: true} |
Load next batch using cursor | Infinite scroll, real-time data |
| Load more (append) | {items: [], page: 0, hasMore} |
Append new items to existing array | Social media feeds, infinite scroll |
| Virtual scrolling | {scrollTop, visibleRange} |
Render only visible items | Very large lists (10k+ items) |
Pattern 1: Page-based pagination
function PaginatedList({ items }) {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// Calculate pagination values
const totalPages = Math.ceil(items.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentItems = items.slice(startIndex, endIndex);
const goToPage = (page) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
return (
<div>
{/* Items for current page */}
<div>
{currentItems.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
{/* Pagination controls */}
<div>
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
{/* Page numbers */}
<div>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => goToPage(page)}
disabled={page === currentPage}
>
{page}
</button>
))}
</div>
</div>
);
}
Pattern 2: Infinite scroll (load more)
function InfiniteScrollList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await fetch(
`/api/items?page=${page}&limit=20`
);
const newItems = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
// Append new items to existing
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} finally {
setLoading(false);
}
};
// Load initial data
useEffect(() => {
loadMore();
}, []);
// Intersection Observer for auto-load
const observerRef = useRef();
const lastItemRef = useCallback(node => {
if (loading) return;
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
}
);
if (node) observerRef.current.observe(node);
}, [loading, hasMore]);
return (
<div>
{items.map((item, index) => {
// Attach ref to last item
const isLast = index === items.length - 1;
return (
<div
key={item.id}
ref={isLast ? lastItemRef : null}
>
{item.name}
</div>
);
})}
{loading && <div>Loading...</div>}
{!hasMore && <div>No more items</div>}
</div>
);
}
Virtual Scrolling: For lists with 10,000+ items, use libraries like
react-window
or react-virtual. They only render visible items (+ buffer), drastically improving performance.
Example: 100,000 items but only render 20 visible = 5000x fewer DOM nodes.
4. Selection State for Multi-select Components
| Pattern | State Type | Operations | Use Case |
|---|---|---|---|
| Set of IDs | Set<id> |
add, delete, has - O(1) | Efficient lookup and toggle |
| Array of IDs | string[] |
includes, filter - O(n) | Simple, serializable for API |
| Boolean map | {[id]: boolean} |
Direct property access - O(1) | Quick lookup, works with forms |
| Select all state | {selected: Set, allSelected: bool} |
Track if all items selected | Bulk operations, select all checkbox |
Example: Multi-select with Set (recommended)
function MultiSelectList({ items }) {
const [selected, setSelected] = useState(new Set());
const toggleItem = (id) => {
setSelected(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
const selectAll = () => {
setSelected(new Set(items.map(item => item.id)));
};
const clearSelection = () => {
setSelected(new Set());
};
const isAllSelected = selected.size === items.length;
const isSomeSelected = selected.size > 0 && selected.size < items.length;
const deleteSelected = () => {
// Convert Set to Array for API call
const selectedIds = Array.from(selected);
console.log('Deleting:', selectedIds);
// After deletion, clear selection
clearSelection();
};
return (
<div>
{/* Select all checkbox */}
<label>
<input
type="checkbox"
checked={isAllSelected}
ref={input => {
if (input) input.indeterminate = isSomeSelected;
}}
onChange={(e) => {
if (e.target.checked) {
selectAll();
} else {
clearSelection();
}
}}
/>
Select All
</label>
{/* Item list */}
{items.map(item => (
<div key={item.id}>
<label>
<input
type="checkbox"
checked={selected.has(item.id)}
onChange={() => toggleItem(item.id)}
/>
{item.name}
</label>
</div>
))}
{/* Bulk actions */}
<div>
<span>{selected.size} selected</span>
{selected.size > 0 && (
<>
<button onClick={deleteSelected}>Delete Selected</button>
<button onClick={clearSelection}>Clear Selection</button>
</>
)}
</div>
</div>
);
}
Set vs Array for Selection:
- ✅ Set: O(1) add/delete/has, better performance for large lists
- ⚠️ Array: O(n) operations, but easier to serialize for APIs
- 💡 Best of both: Use Set internally, convert to Array when needed:
Array.from(selectedSet) - 🔄 Convert back:
new Set(selectedArray)
5. Drag and Drop State Management
| State | Purpose | Update Timing | Value |
|---|---|---|---|
| draggingId | Track which item is being dragged | onDragStart / onDragEnd | ID of dragged item or null |
| dragOverId | Track drop target (visual feedback) | onDragOver / onDragLeave | ID of hovered item or null |
| items order | List order after drop | onDrop | Reordered array |
| dragData | Data being transferred | Set in onDragStart, read in onDrop | Item data or ID |
Example: Drag and drop reordering
function DraggableList() {
const [items, setItems] = useState([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' },
{ id: 4, text: 'Item 4' }
]);
const [draggingId, setDraggingId] = useState(null);
const [dragOverId, setDragOverId] = useState(null);
const handleDragStart = (e, id) => {
setDraggingId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e, id) => {
e.preventDefault(); // Required to allow drop
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = (e, dropTargetId) => {
e.preventDefault();
if (draggingId === dropTargetId) {
// Dropped on itself, do nothing
setDraggingId(null);
setDragOverId(null);
return;
}
// Reorder items
setItems(prev => {
const dragIndex = prev.findIndex(item => item.id === draggingId);
const dropIndex = prev.findIndex(item => item.id === dropTargetId);
const newItems = [...prev];
const [draggedItem] = newItems.splice(dragIndex, 1);
newItems.splice(dropIndex, 0, draggedItem);
return newItems;
});
setDraggingId(null);
setDragOverId(null);
};
const handleDragEnd = () => {
setDraggingId(null);
setDragOverId(null);
};
return (
<div>
{items.map(item => (
<div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, item.id)}
onDragOver={(e) => handleDragOver(e, item.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, item.id)}
onDragEnd={handleDragEnd}
style={{
opacity: draggingId === item.id ? 0.5 : 1,
backgroundColor: dragOverId === item.id ? '#e0e0e0' : 'white',
border: '1px solid #ccc',
padding: '10px',
margin: '5px',
cursor: 'move'
}}
>
{item.text}
</div>
))}
</div>
);
}
Drag and Drop Complexity: Native HTML5 drag and drop has quirks and limited mobile support.
For production apps, consider libraries like
react-beautiful-dnd, dnd-kit, or
react-dnd which handle edge cases, accessibility, and touch events.
6. Search and Filter State with Debouncing
| Technique | Implementation | Benefit | Delay |
|---|---|---|---|
| Debounce | Wait N ms after last keystroke before searching | Reduce API calls, avoid search on every keystroke | 300-500ms typical |
| Throttle | Execute search at most once every N ms | Limit rate of expensive operations | 200-500ms typical |
| Immediate local search | Filter state immediately for local data | Instant feedback for small datasets | 0ms - no delay |
| Debounced + Loading | Show loading state during debounce period | Visual feedback that search is pending | Debounce + network time |
Example: Custom debounce hook for search
import { useState, useEffect } from 'react';
// Reusable debounce hook
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set timeout to update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cleanup: cancel timeout if value changes before delay expires
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage in search component
function SearchableList({ items }) {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// Only filter when debounced value changes
const filteredItems = useMemo(() => {
console.log('Filtering with:', debouncedSearchTerm);
if (!debouncedSearchTerm) return items;
return items.filter(item =>
item.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
);
}, [items, debouncedSearchTerm]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<p>Searching for: "{debouncedSearchTerm}"</p>
<div>
{filteredItems.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
}
Example: Debounced API search with loading state
function APISearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
// Don't search if query is empty
if (!debouncedQuery) {
setResults([]);
return;
}
const searchAPI = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`
);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setLoading(false);
}
};
searchAPI();
}, [debouncedQuery]);
// Show typing indicator
const isTyping = query !== debouncedQuery;
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
{isTyping && <span>⌨️ Typing...</span>}
{loading && <span>🔍 Searching...</span>}
<div>
{results.length === 0 && debouncedQuery && !loading && (
<p>No results found for "{debouncedQuery}"</p>
)}
{results.map(result => (
<div key={result.id}>
{result.name} - ${result.price}
</div>
))}
</div>
</div>
);
}
Example: Advanced search with multiple filters
function AdvancedSearch() {
const [filters, setFilters] = useState({
search: '',
category: 'all',
minPrice: 0,
maxPrice: 1000,
inStock: false
});
// Debounce only the search text, not the other filters
const debouncedSearch = useDebounce(filters.search, 300);
const updateFilter = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
// Combine all filters
const filteredItems = useMemo(() => {
return items.filter(item => {
// Text search (debounced)
if (debouncedSearch && !item.name.toLowerCase().includes(
debouncedSearch.toLowerCase()
)) {
return false;
}
// Category filter (immediate)
if (filters.category !== 'all' && item.category !== filters.category) {
return false;
}
// Price range (immediate)
if (item.price < filters.minPrice || item.price > filters.maxPrice) {
return false;
}
// Stock filter (immediate)
if (filters.inStock && !item.inStock) {
return false;
}
return true;
});
}, [debouncedSearch, filters.category, filters.minPrice, filters.maxPrice, filters.inStock]);
return (
<div>
{/* Search input - debounced */}
<input
type="text"
value={filters.search}
onChange={(e) => updateFilter('search', e.target.value)}
placeholder="Search..."
/>
{/* Category - immediate */}
<select
value={filters.category}
onChange={(e) => updateFilter('category', e.target.value)}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
{/* Price range - immediate */}
<input
type="range"
min="0"
max="1000"
value={filters.minPrice}
onChange={(e) => updateFilter('minPrice', parseInt(e.target.value))}
/>
{/* In stock - immediate */}
<label>
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => updateFilter('inStock', e.target.checked)}
/>
In Stock Only
</label>
<p>{filteredItems.length} results</p>
{filteredItems.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
Debounce Best Practices:
- Use 300-500ms delay for search inputs (balances responsiveness vs API load)
- Debounce text search, but apply other filters (checkboxes, selects) immediately
- Show "typing" indicator when input value differs from debounced value
- Cancel pending API requests when component unmounts (use AbortController)
- For very large local datasets (>10k items), debounce the filter calculation too
- Consider using libraries like
lodash.debounceoruse-debounce
List and Collection State Summary:
- CRUD operations - Use functional updates with map/filter for immutability
- Unique keys - Always use stable IDs (not index) for React keys
- Filtering/Sorting - Use useMemo for derived state, avoid mutating with sort()
- Pagination - Page-based for traditional, cursor-based for infinite scroll
- Selection - Use Set for O(1) operations, convert to Array for APIs
- Drag and drop - Track draggingId and dragOverId for visual feedback
- Search debouncing - 300-500ms delay for text input, immediate for other filters
- Virtual scrolling - Use react-window for lists with 10k+ items
- Performance - Memoize expensive calculations, normalize for large datasets