Custom Hook Development Guide
This guide covers best practices for creating and testing custom React hooks in Elite Events.
When to Create a Hook
Create a custom hook when you need to:
- Share stateful logic between multiple components
- Encapsulate complex state management
- Abstract API calls or data fetching
- Combine multiple React hooks into reusable logic
- Handle subscriptions (WebSocket, events)
Don't create a hook for:
- Simple one-off logic
- Pure utility functions (use regular functions instead)
- Logic that doesn't use React hooks
Hook Location
All hooks live in src/hooks/:
src/hooks/
├── index.ts # Barrel export
├── useMyHook.ts # Hook implementation
└── __tests__/
└── useMyHook.test.ts # Hook tests
Hook Structure Template
// src/hooks/useMyHook.ts
'use client'; // Required for client-side hooks
import { useState, useEffect, useCallback, useMemo } from 'react';
/**
* Configuration options for the hook
*/
interface UseMyHookOptions {
/** Initial value */
initialValue?: string;
/** Callback when value changes */
onChange?: (value: string) => void;
/** Enable debug logging */
debug?: boolean;
}
/**
* Return type of the hook
*/
interface UseMyHookReturn {
/** Current value */
value: string;
/** Loading state */
isLoading: boolean;
/** Error message if any */
error: string | null;
/** Update the value */
setValue: (value: string) => void;
/** Reset to initial state */
reset: () => void;
}
/**
* Hook description - what it does and when to use it
*
* @param options - Configuration options
* @returns Hook state and methods
*
* @example
* ```tsx
* function MyComponent() {
* const { value, setValue, isLoading } = useMyHook({
* initialValue: 'hello',
* onChange: (v) => console.log('Changed:', v),
* });
*
* return <input value={value} onChange={(e) => setValue(e.target.value)} />;
* }
* ```
*/
export function useMyHook(options: UseMyHookOptions = {}): UseMyHookReturn {
const { initialValue = '', onChange, debug = false } = options;
// State declarations
const [value, setValueState] = useState(initialValue);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Memoized values
const derivedValue = useMemo(() => {
return value.toUpperCase();
}, [value]);
// Effects
useEffect(() => {
if (debug) {
console.log('[useMyHook] Value changed:', value);
}
}, [value, debug]);
// Callbacks (memoized to prevent unnecessary re-renders)
const setValue = useCallback((newValue: string) => {
setValueState(newValue);
onChange?.(newValue);
}, [onChange]);
const reset = useCallback(() => {
setValueState(initialValue);
setError(null);
}, [initialValue]);
// Cleanup on unmount
useEffect(() => {
return () => {
// Cleanup subscriptions, timers, etc.
};
}, []);
return {
value,
isLoading,
error,
setValue,
reset,
};
}
Naming Conventions
| Convention | Example | Description |
|---|---|---|
Prefix with use | useProducts | Required by React |
| Descriptive name | useProductSearch | Not usePSearch |
| Action-based for mutations | useAddToCart | Indicates action |
| Resource-based for data | useCategories | Indicates data fetched |
Common Hook Patterns
Data Fetching Hook
export function useProducts(categoryId?: number) {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/products?category=${categoryId}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setProducts(data.products);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setIsLoading(false);
}
}
fetchProducts();
return () => controller.abort(); // Cleanup on unmount
}, [categoryId]);
return { products, isLoading, error };
}
WebSocket/Subscription Hook
export function useSocket(url: string) {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<unknown>(null);
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = () => setIsConnected(true);
socket.onclose = () => setIsConnected(false);
socket.onmessage = (event) => setLastMessage(JSON.parse(event.data));
return () => {
socket.close();
};
}, [url]);
const send = useCallback((data: unknown) => {
socketRef.current?.send(JSON.stringify(data));
}, []);
return { isConnected, lastMessage, send };
}
Form State Hook
export function useFormField<T>(initialValue: T, validate?: (value: T) => string | null) {
const [value, setValue] = useState(initialValue);
const [touched, setTouched] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChange = useCallback((newValue: T) => {
setValue(newValue);
if (touched && validate) {
setError(validate(newValue));
}
}, [touched, validate]);
const handleBlur = useCallback(() => {
setTouched(true);
if (validate) {
setError(validate(value));
}
}, [value, validate]);
const reset = useCallback(() => {
setValue(initialValue);
setTouched(false);
setError(null);
}, [initialValue]);
return {
value,
touched,
error,
isValid: !error,
onChange: handleChange,
onBlur: handleBlur,
reset,
};
}
Pagination Hook
export function usePagination<T>(
fetchFn: (page: number, limit: number) => Promise<{ items: T[]; total: number }>,
options: { initialPage?: number; limit?: number } = {}
) {
const { initialPage = 1, limit = 20 } = options;
const [page, setPage] = useState(initialPage);
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const totalPages = Math.ceil(total / limit);
const hasNext = page < totalPages;
const hasPrev = page > 1;
useEffect(() => {
async function load() {
setIsLoading(true);
const result = await fetchFn(page, limit);
setItems(result.items);
setTotal(result.total);
setIsLoading(false);
}
load();
}, [page, limit, fetchFn]);
return {
items,
page,
totalPages,
hasNext,
hasPrev,
isLoading,
nextPage: () => hasNext && setPage(p => p + 1),
prevPage: () => hasPrev && setPage(p => p - 1),
goToPage: setPage,
};
}
Testing Hooks
Every hook MUST have tests in src/hooks/__tests__/:
// src/hooks/__tests__/useMyHook.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { useMyHook } from '../useMyHook';
describe('useMyHook', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useMyHook());
expect(result.current.value).toBe('');
expect(result.current.isLoading).toBe(false);
});
it('should initialize with provided value', () => {
const { result } = renderHook(() => useMyHook({ initialValue: 'test' }));
expect(result.current.value).toBe('test');
});
it('should update value when setValue is called', () => {
const { result } = renderHook(() => useMyHook());
act(() => {
result.current.setValue('new value');
});
expect(result.current.value).toBe('new value');
});
it('should call onChange callback', () => {
const onChange = jest.fn();
const { result } = renderHook(() => useMyHook({ onChange }));
act(() => {
result.current.setValue('test');
});
expect(onChange).toHaveBeenCalledWith('test');
});
it('should reset to initial value', () => {
const { result } = renderHook(() => useMyHook({ initialValue: 'initial' }));
act(() => {
result.current.setValue('changed');
});
expect(result.current.value).toBe('changed');
act(() => {
result.current.reset();
});
expect(result.current.value).toBe('initial');
});
it('should handle async operations', async () => {
const { result } = renderHook(() => useMyAsyncHook());
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
});
Best Practices
Do's
- Use TypeScript - Define interfaces for options and return types
- Document with JSDoc - Include description, params, examples
- Memoize callbacks - Use
useCallbackfor functions returned - Memoize expensive values - Use
useMemofor computed values - Handle cleanup - Return cleanup function from
useEffect - Handle errors - Catch and expose errors in return
- Export from index - Add to
src/hooks/index.ts
Don'ts
- Don't call hooks conditionally - Breaks React rules
- Don't mutate state directly - Always use setter functions
- Don't return unstable references - Memoize objects/arrays
- Don't ignore dependencies - Include all deps in arrays
- Don't forget cleanup - Cancel requests, clear timers
Existing Hooks Reference
| Hook | Purpose | Location |
|---|---|---|
useDebounce | Debounce values/callbacks | src/hooks/useDebounce.ts |
useProducts | Fetch product data | src/hooks/useProducts.ts |
useCategories | Fetch categories | src/hooks/useCategories.ts |
useSocket | WebSocket connection | src/hooks/useSocket.ts |
useToast | Toast notifications | src/hooks/useToast.ts |
useTheme | Theme management | src/hooks/useTheme.ts |
useFocusTrap | Accessibility focus trap | src/hooks/useFocusTrap.ts |
Related Documentation
- Redux Patterns Guide
- Testing Guide
- Component Development