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

208
hooks/useScrollPosition.ts Normal file
View File

@@ -0,0 +1,208 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
interface UseScrollPositionOptions {
key: string
enabled?: boolean
debounceMs?: number
}
/**
* Custom hook for preserving scroll position across navigation
* Validates: Requirements 7.4
*/
export function useScrollPosition(options: UseScrollPositionOptions) {
const { key, enabled = true, debounceMs = 100 } = options
const scrollPositionRef = useRef<number>(0)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isRestoringRef = useRef(false)
// Save scroll position to sessionStorage
const saveScrollPosition = useCallback((position: number) => {
if (!enabled) return
try {
sessionStorage.setItem(`scroll-${key}`, position.toString())
scrollPositionRef.current = position
} catch (error) {
console.warn('Failed to save scroll position:', error)
}
}, [key, enabled])
// Restore scroll position from sessionStorage
const restoreScrollPosition = useCallback(() => {
if (!enabled) return
try {
const savedPosition = sessionStorage.getItem(`scroll-${key}`)
if (savedPosition !== null) {
const position = parseInt(savedPosition, 10)
if (!isNaN(position)) {
isRestoringRef.current = true
window.scrollTo(0, position)
scrollPositionRef.current = position
// Reset flag after a short delay
setTimeout(() => {
isRestoringRef.current = false
}, 100)
}
}
} catch (error) {
console.warn('Failed to restore scroll position:', error)
}
}, [key, enabled])
// Clear saved scroll position
const clearScrollPosition = useCallback(() => {
try {
sessionStorage.removeItem(`scroll-${key}`)
scrollPositionRef.current = 0
} catch (error) {
console.warn('Failed to clear scroll position:', error)
}
}, [key])
// Handle scroll events with debouncing
useEffect(() => {
if (!enabled) return
const handleScroll = () => {
// Don't save position if we're currently restoring
if (isRestoringRef.current) return
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Debounce the save operation
timeoutRef.current = setTimeout(() => {
const position = window.scrollY || window.pageYOffset
saveScrollPosition(position)
}, debounceMs)
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
window.removeEventListener('scroll', handleScroll)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [enabled, debounceMs, saveScrollPosition])
// Restore scroll position on mount
useEffect(() => {
if (enabled) {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
restoreScrollPosition()
})
}
}, [enabled, restoreScrollPosition])
return {
saveScrollPosition,
restoreScrollPosition,
clearScrollPosition,
currentPosition: scrollPositionRef.current
}
}
/**
* Hook for preserving scroll position within a specific container element
*/
export function useContainerScrollPosition(options: UseScrollPositionOptions) {
const { key, enabled = true, debounceMs = 100 } = options
const containerRef = useRef<HTMLElement | null>(null)
const scrollPositionRef = useRef<number>(0)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isRestoringRef = useRef(false)
// Save scroll position
const saveScrollPosition = useCallback((position: number) => {
if (!enabled) return
try {
sessionStorage.setItem(`container-scroll-${key}`, position.toString())
scrollPositionRef.current = position
} catch (error) {
console.warn('Failed to save container scroll position:', error)
}
}, [key, enabled])
// Restore scroll position
const restoreScrollPosition = useCallback(() => {
if (!enabled || !containerRef.current) return
try {
const savedPosition = sessionStorage.getItem(`container-scroll-${key}`)
if (savedPosition !== null) {
const position = parseInt(savedPosition, 10)
if (!isNaN(position)) {
isRestoringRef.current = true
containerRef.current.scrollTop = position
scrollPositionRef.current = position
setTimeout(() => {
isRestoringRef.current = false
}, 100)
}
}
} catch (error) {
console.warn('Failed to restore container scroll position:', error)
}
}, [key, enabled])
// Clear saved scroll position
const clearScrollPosition = useCallback(() => {
try {
sessionStorage.removeItem(`container-scroll-${key}`)
scrollPositionRef.current = 0
} catch (error) {
console.warn('Failed to clear container scroll position:', error)
}
}, [key])
// Set up scroll listener on container
useEffect(() => {
if (!enabled || !containerRef.current) return
const container = containerRef.current
const handleScroll = () => {
if (isRestoringRef.current) return
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
const position = container.scrollTop
saveScrollPosition(position)
}, debounceMs)
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => {
container.removeEventListener('scroll', handleScroll)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [enabled, debounceMs, saveScrollPosition])
return {
containerRef,
saveScrollPosition,
restoreScrollPosition,
clearScrollPosition,
currentPosition: scrollPositionRef.current
}
}