feat: implement client-side state management hooks

- Created useSearch hook for search state and debouncing
- Created useFilters hook for filter state management
- Created useOptimisticUpdate hook for optimistic UI updates
- Created useScrollPosition hook for scroll preservation
- Updated SearchBar, GlobalSearch, and ListDetailView to use new hooks
- Added comprehensive documentation for all hooks

Validates: Requirements 6.4, 7.3, 7.4, 8.3
Task: 9. Implement client-side state management
This commit is contained in:
2025-12-01 20:11:41 +00:00
parent 0143097e69
commit e2e3581d06
10 changed files with 821 additions and 52 deletions

View File

@@ -4,6 +4,7 @@ import { useState, useCallback } from 'react'
import SearchBar from './SearchBar'
import SearchResults from './SearchResults'
import { searchItems } from '@/lib/actions'
import { useSearch } from '@/hooks'
interface ItemWithList {
id: string
@@ -22,13 +23,10 @@ interface ItemWithList {
}
export default function GlobalSearch() {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
const [isSearching, setIsSearching] = useState(false)
const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query)
if (!query.trim()) {
setSearchResults([])
setIsSearching(false)
@@ -47,10 +45,14 @@ export default function GlobalSearch() {
}
}, [])
const { debouncedQuery, clearSearch } = useSearch({
onSearch: handleSearch
})
const handleClose = useCallback(() => {
setSearchQuery('')
clearSearch()
setSearchResults([])
}, [])
}, [clearSearch])
return (
<div className="space-y-4">
@@ -59,10 +61,10 @@ export default function GlobalSearch() {
placeholder="Search all tasks..."
/>
{searchQuery.trim() && (
{debouncedQuery.trim() && (
<SearchResults
results={searchResults}
query={searchQuery}
query={debouncedQuery}
onClose={handleClose}
/>
)}

View File

@@ -1,11 +1,12 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import { useState, useCallback, useMemo, useEffect } from 'react'
import ItemCard from './ItemCard'
import SearchBar from './SearchBar'
import FilterPanel, { FilterState } from './FilterPanel'
import FilterPanel from './FilterPanel'
import SearchResults from './SearchResults'
import { searchItems, getItemsByFilter } from '@/lib/actions'
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
interface Item {
id: string
@@ -35,21 +36,13 @@ interface ListDetailViewProps {
}
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
const [isSearching, setIsSearching] = useState(false)
const [filters, setFilters] = useState<FilterState>({
priority: null,
completed: null,
tags: [],
dueDateRange: { start: null, end: null },
sortBy: 'createdAt',
sortOrder: 'desc'
})
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
// Use scroll position preservation hook
useScrollPosition({ key: `list-${listId}` })
// Extract all unique tags from items
const availableTags = useMemo(() => {
const tagSet = new Set<string>()
@@ -64,10 +57,8 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
return Array.from(tagSet).sort()
}, [initialItems])
// Handle search
const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query)
// Use search hook
const handleSearchCallback = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setIsSearching(false)
@@ -81,13 +72,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
} catch (error) {
console.error('Search error:', error)
setSearchResults([])
} finally {
setIsSearching(false)
}
}, [listId])
// Handle filter changes
const handleFiltersChange = useCallback(async (newFilters: FilterState) => {
setFilters(newFilters)
const { debouncedQuery } = useSearch({
onSearch: handleSearchCallback
})
// Use filters hook
const handleFiltersCallback = useCallback(async (newFilters: any) => {
try {
const results = await getItemsByFilter(listId, {
priority: newFilters.priority,
@@ -104,8 +99,17 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
}
}, [listId, initialItems])
const { filters, setFilters } = useFilters({
onFiltersChange: handleFiltersCallback
})
// Apply initial filters on mount
useEffect(() => {
handleFiltersCallback(filters)
}, []) // Only run on mount
// Determine which items to display
const displayItems = searchQuery.trim() ? searchResults : filteredItems
const displayItems = debouncedQuery.trim() ? searchResults : filteredItems
const completedItems = displayItems.filter(item => item.completed)
const incompleteItems = displayItems.filter(item => !item.completed)
@@ -113,22 +117,22 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
<div className="space-y-6">
{/* Search Bar */}
<SearchBar
onSearch={handleSearch}
onSearch={handleSearchCallback}
placeholder="Search tasks in this list..."
/>
{/* Filter Panel */}
<FilterPanel
filters={filters}
onFiltersChange={handleFiltersChange}
onFiltersChange={setFilters}
availableTags={availableTags}
/>
{/* Results */}
{searchQuery.trim() ? (
{debouncedQuery.trim() ? (
<SearchResults
results={searchResults}
query={searchQuery}
query={debouncedQuery}
/>
) : (
<div className="space-y-6">

View File

@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Search, X } from 'lucide-react'
import { useSearch } from '@/hooks'
interface SearchBarProps {
onSearch: (query: string) => void
@@ -16,21 +16,10 @@ export default function SearchBar({
debounceMs = 300,
initialValue = ''
}: SearchBarProps) {
const [query, setQuery] = useState(initialValue)
// Debounced search effect
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query)
}, debounceMs)
return () => clearTimeout(timer)
}, [query, debounceMs, onSearch])
const handleClear = useCallback(() => {
setQuery('')
onSearch('')
}, [onSearch])
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
debounceMs,
onSearch
})
return (
<div className="relative">
@@ -48,13 +37,18 @@ export default function SearchBar({
/>
{query && (
<button
onClick={handleClear}
onClick={clearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Clear search"
>
<X size={20} />
</button>
)}
{isDebouncing && (
<div className="absolute right-10 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
</div>
</div>
)