feat: add custom hooks for common patterns

- Add useLoadingState hook for managing loading states
- Add useDebounce hook for debouncing values/callbacks
- Add useApiCall hook for API call management with error handling
- Add usePagination hook for pagination logic
- Create centralized hooks/index.ts for exports
- Reduce code duplication across components
This commit is contained in:
Vivek R
2025-07-16 20:01:32 +05:30
parent 82fdd06873
commit 295571f81a
5 changed files with 340 additions and 0 deletions

5
src/hooks/index.ts Normal file
View File

@@ -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';

116
src/hooks/useApiCall.ts Normal file
View File

@@ -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<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
call: (...args: any[]) => Promise<T | null>;
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<T>(
apiFunction: (...args: any[]) => Promise<T>,
options: ApiCallOptions = {}
): ApiCallState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const abortControllerRef = useRef<AbortController | null>(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<T | null> => {
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 };
}

48
src/hooks/useDebounce.ts Normal file
View File

@@ -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<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(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<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const callbackRef = useRef(callback);
// Update callback ref on each render to avoid stale closures
callbackRef.current = callback;
return useRef(
((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}) as T
).current;
}

View File

@@ -0,0 +1,48 @@
import { useState, useCallback } from 'react';
interface LoadingState<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
execute: (...args: any[]) => Promise<T>;
reset: () => void;
}
/**
* Custom hook for managing loading states with error handling
* Reduces boilerplate code for async operations
*/
export function useLoadingState<T>(
asyncFunction: (...args: any[]) => Promise<T>
): LoadingState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(
async (...args: any[]): Promise<T> => {
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 };
}

123
src/hooks/usePagination.ts Normal file
View File

@@ -0,0 +1,123 @@
import { useState, useMemo, useCallback } from 'react';
interface PaginationOptions {
initialPage?: number;
initialPageSize?: number;
pageSizeOptions?: number[];
}
interface PaginationResult<T> {
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<T>(
data: T[],
options: PaginationOptions = {}
): PaginationResult<T> {
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
};
}