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
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
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.debounce or use-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