diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..34163e3 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,5 @@ +// Export all custom hooks from a single entry point +export { useLoadingState } from './useLoadingState'; +export { useDebounce, useDebouncedCallback } from './useDebounce'; +export { useApiCall } from './useApiCall'; +export { usePagination } from './usePagination'; \ No newline at end of file diff --git a/src/hooks/useApiCall.ts b/src/hooks/useApiCall.ts new file mode 100644 index 0000000..e4c1295 --- /dev/null +++ b/src/hooks/useApiCall.ts @@ -0,0 +1,116 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; + +interface ApiCallOptions { + onSuccess?: (data: any) => void; + onError?: (error: Error) => void; + showErrorToast?: boolean; + showSuccessToast?: boolean; + successMessage?: string; + errorMessage?: string; +} + +interface ApiCallState { + data: T | null; + isLoading: boolean; + error: Error | null; + call: (...args: any[]) => Promise; + reset: () => void; +} + +/** + * Custom hook for making API calls with consistent error handling and loading states + * Includes automatic toast notifications and cleanup on unmount + */ +export function useApiCall( + apiFunction: (...args: any[]) => Promise, + options: ApiCallOptions = {} +): ApiCallState { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const isMountedRef = useRef(true); + + const { + onSuccess, + onError, + showErrorToast = true, + showSuccessToast = false, + successMessage = 'Operation completed successfully', + errorMessage + } = options; + + const call = useCallback( + async (...args: any[]): Promise => { + try { + // Cancel any pending request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller + abortControllerRef.current = new AbortController(); + + setIsLoading(true); + setError(null); + + const result = await apiFunction(...args); + + // Only update state if component is still mounted + if (!isMountedRef.current) return null; + + setData(result); + + if (showSuccessToast) { + // TODO: Implement toast notification + console.log('Success:', successMessage); + } + + onSuccess?.(result); + return result; + } catch (err) { + // Ignore aborted requests + if (err instanceof Error && err.name === 'AbortError') { + return null; + } + + // Only update state if component is still mounted + if (!isMountedRef.current) return null; + + const error = err instanceof Error ? err : new Error('An error occurred'); + setError(error); + + if (showErrorToast) { + // TODO: Implement toast notification + console.error('Error:', errorMessage || error.message); + } + + onError?.(error); + return null; + } finally { + if (isMountedRef.current) { + setIsLoading(false); + } + } + }, + [apiFunction, onSuccess, onError, showErrorToast, showSuccessToast, successMessage, errorMessage] + ); + + const reset = useCallback(() => { + setData(null); + setError(null); + setIsLoading(false); + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + isMountedRef.current = false; + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + return { data, isLoading, error, call, reset }; +} \ No newline at end of file diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..d79dbb5 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,48 @@ +import { useEffect, useState, useRef } from 'react'; + +/** + * Custom hook that debounces a value + * Useful for search inputs and reducing API calls + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * Custom hook that returns a debounced callback + * The callback will only be invoked after the delay has passed since the last call + */ +export function useDebouncedCallback any>( + callback: T, + delay: number +): T { + const timeoutRef = useRef(null); + const callbackRef = useRef(callback); + + // Update callback ref on each render to avoid stale closures + callbackRef.current = callback; + + return useRef( + ((...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }) as T + ).current; +} \ No newline at end of file diff --git a/src/hooks/useLoadingState.ts b/src/hooks/useLoadingState.ts new file mode 100644 index 0000000..e33cd65 --- /dev/null +++ b/src/hooks/useLoadingState.ts @@ -0,0 +1,48 @@ +import { useState, useCallback } from 'react'; + +interface LoadingState { + data: T | null; + isLoading: boolean; + error: Error | null; + execute: (...args: any[]) => Promise; + reset: () => void; +} + +/** + * Custom hook for managing loading states with error handling + * Reduces boilerplate code for async operations + */ +export function useLoadingState( + asyncFunction: (...args: any[]) => Promise +): LoadingState { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const execute = useCallback( + async (...args: any[]): Promise => { + try { + setIsLoading(true); + setError(null); + const result = await asyncFunction(...args); + setData(result); + return result; + } catch (err) { + const error = err instanceof Error ? err : new Error('An error occurred'); + setError(error); + throw error; + } finally { + setIsLoading(false); + } + }, + [asyncFunction] + ); + + const reset = useCallback(() => { + setData(null); + setError(null); + setIsLoading(false); + }, []); + + return { data, isLoading, error, execute, reset }; +} \ No newline at end of file diff --git a/src/hooks/usePagination.ts b/src/hooks/usePagination.ts new file mode 100644 index 0000000..0a20ac1 --- /dev/null +++ b/src/hooks/usePagination.ts @@ -0,0 +1,123 @@ +import { useState, useMemo, useCallback } from 'react'; + +interface PaginationOptions { + initialPage?: number; + initialPageSize?: number; + pageSizeOptions?: number[]; +} + +interface PaginationResult { + currentPage: number; + pageSize: number; + totalPages: number; + totalItems: number; + paginatedData: T[]; + goToPage: (page: number) => void; + nextPage: () => void; + previousPage: () => void; + setPageSize: (size: number) => void; + canGoNext: boolean; + canGoPrevious: boolean; + pageRange: number[]; +} + +/** + * Custom hook for handling pagination logic + * Returns paginated data and pagination controls + */ +export function usePagination( + data: T[], + options: PaginationOptions = {} +): PaginationResult { + const { + initialPage = 1, + initialPageSize = 10, + pageSizeOptions: _pageSizeOptions = [10, 25, 50, 100] + } = options; + + const [currentPage, setCurrentPage] = useState(initialPage); + const [pageSize, setPageSize] = useState(initialPageSize); + + const totalItems = data.length; + const totalPages = Math.ceil(totalItems / pageSize); + + // Calculate paginated data + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * pageSize; + const endIndex = startIndex + pageSize; + return data.slice(startIndex, endIndex); + }, [data, currentPage, pageSize]); + + // Navigation functions + const goToPage = useCallback((page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }, [totalPages]); + + const nextPage = useCallback(() => { + goToPage(currentPage + 1); + }, [currentPage, goToPage]); + + const previousPage = useCallback(() => { + goToPage(currentPage - 1); + }, [currentPage, goToPage]); + + const handleSetPageSize = useCallback((size: number) => { + setPageSize(size); + // Reset to first page when page size changes + setCurrentPage(1); + }, []); + + // Generate page range for pagination UI + const pageRange = useMemo(() => { + const range: number[] = []; + const maxVisible = 7; // Maximum number of page buttons to show + + if (totalPages <= maxVisible) { + // Show all pages if total is less than max + for (let i = 1; i <= totalPages; i++) { + range.push(i); + } + } else { + // Always show first page + range.push(1); + + if (currentPage > 3) { + range.push(-1); // Ellipsis + } + + // Show pages around current page + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + range.push(i); + } + + if (currentPage < totalPages - 2) { + range.push(-1); // Ellipsis + } + + // Always show last page + if (totalPages > 1) { + range.push(totalPages); + } + } + + return range; + }, [currentPage, totalPages]); + + return { + currentPage, + pageSize, + totalPages, + totalItems, + paginatedData, + goToPage, + nextPage, + previousPage, + setPageSize: handleSetPageSize, + canGoNext: currentPage < totalPages, + canGoPrevious: currentPage > 1, + pageRange + }; +} \ No newline at end of file