'use client' import { useCallback, useEffect, useRef } from 'react' import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { SortState, FilterState } from '../types' export interface UseTableURLConfig { tableId: string persistToUrl: boolean debounceMs?: number } export interface TableURLState { sortBy: string | null sortDirection: 'asc' | 'desc' filters: FilterState page: number search: string } export interface UseTableURLReturn { // Read state from URL getURLState: () => TableURLState // Update individual state pieces setSortToURL: (sort: SortState) => void setFiltersToURL: (filters: FilterState) => void setPageToURL: (page: number) => void setSearchToURL: (search: string) => void // Clear state clearURLState: () => void // Check if URL has state hasURLState: () => boolean } /** * Hook for persisting table state to URL query parameters. * Enables shareability and browser navigation for table state. * * URL params are namespaced with tableId prefix: * - {tableId}_sortBy: column ID * - {tableId}_sortDir: 'asc' | 'desc' * - {tableId}_page: current page number * - {tableId}_search: search term * - {tableId}_filter_{filterKey}: filter values (JSON encoded) * * @example * const urlState = useTableURL({ * tableId: 'firms', * persistToUrl: true, * debounceMs: 300 * }) * * // Read initial state from URL * const initialState = urlState.getURLState() * * // Update URL when state changes * urlState.setSortToURL({ sortBy: 'name', sortDirection: 'asc' }) * urlState.setSearchToURL('acme') */ export function useTableURL({ tableId, persistToUrl, debounceMs = 300, }: UseTableURLConfig): UseTableURLReturn { const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() // Debounce timer ref const debounceTimerRef = useRef(null) // Track if we're currently applying URL changes to avoid loops const isApplyingURLRef = useRef(false) /** * Build namespaced param key */ const getParamKey = useCallback((key: string): string => { return `${tableId}_${key}` }, [tableId]) /** * Parse URL state from current search params */ const getURLState = useCallback((): TableURLState => { if (!persistToUrl) { return { sortBy: null, sortDirection: 'asc', filters: {}, page: 1, search: '', } } const sortBy = searchParams.get(getParamKey('sortBy')) const sortDirection = searchParams.get(getParamKey('sortDir')) as 'asc' | 'desc' | null const page = parseInt(searchParams.get(getParamKey('page')) || '1', 10) const search = searchParams.get(getParamKey('search')) || '' // Parse filters (any param starting with tableId_filter_) const filters: FilterState = {} const filterPrefix = getParamKey('filter_') searchParams.forEach((value, key) => { if (key.startsWith(filterPrefix)) { const filterKey = key.substring(filterPrefix.length) try { // Try to parse as JSON first (for complex values) filters[filterKey] = JSON.parse(value) } catch { // Fall back to raw string value filters[filterKey] = value } } }) return { sortBy: sortBy || null, sortDirection: sortDirection || 'asc', filters, page: isNaN(page) || page < 1 ? 1 : page, search, } }, [searchParams, persistToUrl, getParamKey]) /** * Update URL with new params (debounced) */ const updateURL = useCallback((updates: Record) => { if (!persistToUrl) return // Clear existing debounce timer if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } // Debounce the URL update debounceTimerRef.current = setTimeout(() => { const newParams = new URLSearchParams(searchParams.toString()) // Apply updates Object.entries(updates).forEach(([key, value]) => { if (value === null || value === '') { newParams.delete(key) } else { newParams.set(key, value) } }) // Only update if params actually changed const newParamsString = newParams.toString() const currentParamsString = searchParams.toString() if (newParamsString !== currentParamsString) { isApplyingURLRef.current = true const newURL = newParamsString ? `${pathname}?${newParamsString}` : pathname router.replace(newURL, { scroll: false }) // Reset flag after a short delay setTimeout(() => { isApplyingURLRef.current = false }, 50) } }, debounceMs) }, [persistToUrl, searchParams, pathname, router, debounceMs]) /** * Update sort state in URL */ const setSortToURL = useCallback((sort: SortState) => { if (!persistToUrl) return updateURL({ [getParamKey('sortBy')]: sort.sortBy, [getParamKey('sortDir')]: sort.sortBy ? sort.sortDirection : null, // Reset to page 1 when sorting changes [getParamKey('page')]: '1', }) }, [persistToUrl, updateURL, getParamKey]) /** * Update filters in URL */ const setFiltersToURL = useCallback((filters: FilterState) => { if (!persistToUrl) return // Build updates object - clear old filters and set new ones const updates: Record = {} const filterPrefix = getParamKey('filter_') // Clear all existing filter params searchParams.forEach((_, key) => { if (key.startsWith(filterPrefix)) { updates[key] = null } }) // Set new filter params Object.entries(filters).forEach(([key, value]) => { const paramKey = `${filterPrefix}${key}` if (value === undefined || value === null || value === '') { updates[paramKey] = null } else if (typeof value === 'object') { // Encode complex values as JSON updates[paramKey] = JSON.stringify(value) } else { updates[paramKey] = String(value) } }) // Reset to page 1 when filters change updates[getParamKey('page')] = '1' updateURL(updates) }, [persistToUrl, updateURL, getParamKey, searchParams]) /** * Update page in URL */ const setPageToURL = useCallback((page: number) => { if (!persistToUrl) return updateURL({ [getParamKey('page')]: page > 1 ? String(page) : null, // Remove param if page 1 }) }, [persistToUrl, updateURL, getParamKey]) /** * Update search term in URL */ const setSearchToURL = useCallback((search: string) => { if (!persistToUrl) return updateURL({ [getParamKey('search')]: search || null, // Reset to page 1 when search changes [getParamKey('page')]: '1', }) }, [persistToUrl, updateURL, getParamKey]) /** * Clear all table-related URL params */ const clearURLState = useCallback(() => { if (!persistToUrl) return const newParams = new URLSearchParams(searchParams.toString()) const keysToDelete: string[] = [] // Find all params for this table newParams.forEach((_, key) => { if (key.startsWith(`${tableId}_`)) { keysToDelete.push(key) } }) // Delete them keysToDelete.forEach(key => newParams.delete(key)) const newURL = newParams.toString() ? `${pathname}?${newParams.toString()}` : pathname router.replace(newURL, { scroll: false }) }, [persistToUrl, searchParams, pathname, router, tableId]) /** * Check if URL has any state for this table */ const hasURLState = useCallback((): boolean => { if (!persistToUrl) return false let hasState = false searchParams.forEach((_, key) => { if (key.startsWith(`${tableId}_`)) { hasState = true } }) return hasState }, [persistToUrl, searchParams, tableId]) // Cleanup debounce timer on unmount useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } } }, []) return { getURLState, setSortToURL, setFiltersToURL, setPageToURL, setSearchToURL, clearURLState, hasURLState, } }