
ReactJS
Custom hooks extract state logic into reusable functions. Master advanced patterns to create composable, testable state abstractions that power enterprise applications.
Beyond simple state wrapping, custom hooks can compose complex logic, handle side effects, and provide clean abstractions.
Custom hooks are functions that use other hooks. They're composable, testable independently, and enable clean separation of concerns.
import { useState, useCallback, useEffect } from 'react';
// Advanced pattern: Hook composition and derivation
function usePaginatedData<T>(
fetchData: (page: number) => Promise<T[]>,
pageSize: number = 10
) {
const [data, setData] = useState<T[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [totalItems, setTotalItems] = useState(0);
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchData(currentPage);
setData(result);
setTotalItems(result.length);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
setLoading(false);
}
}, [currentPage, fetchData]);
useEffect(() => {
fetch();
}, [fetch]);
const goToPage = useCallback((page: number) => {
setCurrentPage(Math.max(1, page));
}, []);
const nextPage = useCallback(() => {
setCurrentPage((prev) => prev + 1);
}, []);
const prevPage = useCallback(() => {
setCurrentPage((prev) => Math.max(1, prev - 1));
}, []);
const totalPages = Math.ceil(totalItems / pageSize);
return {
data,
currentPage,
totalPages,
pageSize,
loading,
error,
goToPage,
nextPage,
prevPage,
canNextPage: currentPage < totalPages,
canPrevPage: currentPage > 1,
refetch: fetch,
};
}
// Usage: Clean, composable interface
function ProductCatalog() {
const {
data: products,
currentPage,
totalPages,
loading,
nextPage,
prevPage,
canNextPage,
} = usePaginatedData(async (page) => {
const response = await fetch(`/api/products?page=${page}`);
return response.json();
});
return (
<div>
{loading && <p>Loading...</p>}
<ul>
{products.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<p>
Page {currentPage} of {totalPages}
</p>
<button onClick={prevPage}>Previous</button>
<button onClick={nextPage} disabled={!canNextPage}>
Next
</button>
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example: Hook composition for feature-rich functionality
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
const updateValue = useCallback(
(update: T | ((prev: T) => T)) => {
setValue((prev) => {
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(prev) : update;
localStorage.setItem(key, JSON.stringify(newValue));
return newValue;
});
},
[key]
);
const removeValue = useCallback(() => {
localStorage.removeItem(key);
setValue(initialValue);
}, [key, initialValue]);
return [value, updateValue, removeValue] as const;
}
function useUndoRedo<T>(initialValue: T) {
const [value, setValue] = useState(initialValue);
const [history, setHistory] = useState<T[]>([initialValue]);
const [historyIndex, setHistoryIndex] = useState(0);
const push = useCallback((newValue: T) => {
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(newValue);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
setValue(newValue);
}, [history, historyIndex]);
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setValue(history[newIndex]);
}
}, [history, historyIndex]);
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setValue(history[newIndex]);
}
}, [history, historyIndex]);
return { value, push, undo, redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1 };
}
// Compose hooks
function useLocalStorageWithHistory<T>(key: string, initialValue: T) {
const [stored, setStored, removeStored] = useLocalStorage(key, initialValue);
const { value, push, undo, redo, canUndo, canRedo } = useUndoRedo(stored);
const update = useCallback(
(newValue: T) => {
push(newValue);
setStored(newValue);
},
[push, setStored]
);
const reset = useCallback(() => {
removeStored();
}, [removeStored]);
return { value, update, undo, redo, canUndo, canRedo, reset };
}</details>
While useState works for simple state, useReducer excels at complex logic with multiple interdependent updates.
useReducer is great when state updates are complex or dependent on previous values. It centralizes logic and makes transitions explicit.
import { useReducer, useCallback } from 'react';
// State machine with useReducer
type LoadingState = 'idle' | 'loading' | 'success' | 'error';
interface DataState {
status: LoadingState;
data: any | null;
error: string | null;
retryCount: number;
}
type DataAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: any }
| { type: 'FETCH_ERROR'; error: string }
| { type: 'RETRY' }
| { type: 'RESET' };
const MAX_RETRIES = 3;
function dataReducer(state: DataState, action: DataAction): DataState {
switch (action.type) {
case 'FETCH_START':
return {
...state,
status: 'loading',
error: null,
};
case 'FETCH_SUCCESS':
return {
status: 'success',
data: action.data,
error: null,
retryCount: 0,
};
case 'FETCH_ERROR':
return {
...state,
status: state.retryCount < MAX_RETRIES ? 'error' : 'error',
error: action.error,
};
case 'RETRY':
return {
...state,
status: 'loading',
retryCount: state.retryCount + 1,
};
case 'RESET':
return {
status: 'idle',
data: null,
error: null,
retryCount: 0,
};
default:
return state;
}
}
function useDataFetching(url: string) {
const [state, dispatch] = useReducer(dataReducer, {
status: 'idle',
data: null,
error: null,
retryCount: 0,
});
const fetch = useCallback(async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', data });
} catch (error) {
dispatch({
type: 'FETCH_ERROR',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [url]);
const retry = useCallback(() => {
dispatch({ type: 'RETRY' });
fetch();
}, [fetch]);
const reset = useCallback(() => {
dispatch({ type: 'RESET' });
}, []);
return {
...state,
fetch,
retry,
reset,
canRetry: state.retryCount < MAX_RETRIES,
};
}
// Usage
function DataDisplay() {
const { status, data, error, fetch, retry, canRetry } = useDataFetching('/api/data');
return (
<div>
{status === 'idle' && <button onClick={fetch}>Load Data</button>}
{status === 'loading' && <p>Loading...</p>}
{status === 'success' && <p>Data: {JSON.stringify(data)}</p>}
{status === 'error' && (
<div>
<p style={{ color: 'red' }}>Error: {error}</p>
{canRetry && <button onClick={retry}>Retry</button>}
</div>
)}
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example: Complex form with useReducer
interface FormState {
fields: Record<string, { value: string; touched: boolean; error?: string }>;
isSubmitting: boolean;
submitError?: string;
}
type FormAction =
| { type: 'FIELD_CHANGE'; field: string; value: string }
| { type: 'FIELD_BLUR'; field: string }
| { type: 'SET_FIELD_ERROR'; field: string; error: string }
| { type: 'START_SUBMIT' }
| { type: 'SUBMIT_ERROR'; error: string }
| { type: 'RESET' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'FIELD_CHANGE':
return {
...state,
fields: {
...state.fields,
[action.field]: {
...state.fields[action.field],
value: action.value,
},
},
};
case 'FIELD_BLUR':
return {
...state,
fields: {
...state.fields,
[action.field]: {
...state.fields[action.field],
touched: true,
},
},
};
case 'SET_FIELD_ERROR':
return {
...state,
fields: {
...state.fields,
[action.field]: {
...state.fields[action.field],
error: action.error,
},
},
};
case 'START_SUBMIT':
return { ...state, isSubmitting: true, submitError: undefined };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, submitError: action.error };
case 'RESET':
return {
fields: {},
isSubmitting: false,
};
default:
return state;
}
}
function useComplexForm(initialFields: Record<string, string>) {
const [state, dispatch] = useReducer(formReducer, {
fields: Object.fromEntries(
Object.entries(initialFields).map(([key, value]) => [
key,
{ value, touched: false },
])
),
isSubmitting: false,
});
const handleFieldChange = useCallback((field: string, value: string) => {
dispatch({ type: 'FIELD_CHANGE', field, value });
}, []);
const handleFieldBlur = useCallback((field: string) => {
dispatch({ type: 'FIELD_BLUR', field });
}, []);
return {
fields: state.fields,
isSubmitting: state.isSubmitting,
submitError: state.submitError,
handleFieldChange,
handleFieldBlur,
dispatch,
};
}</details>
Custom hooks are testable independently of components. This enables reliable, fast tests.
Extract hooks to their own functions. Test them with React Testing Library's renderHook.
import { renderHook, act } from '@testing-library/react';
import { useState, useCallback } from 'react';
// Hook to test
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount((prev) => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
// Tests
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('should increment', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
it('should handle rerender with new initial value', () => {
const { result, rerender } = renderHook(
({ initialValue }) => useCounter(initialValue),
{ initialProps: { initialValue: 10 } }
);
expect(result.current.count).toBe(10);
rerender({ initialValue: 20 });
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(20);
});
});┌──────────────────┬──────────────────┬──────────────────┐
│ Pattern │ Reusability │ Complexity │
├──────────────────┼──────────────────┼──────────────────┤
│ Simple useState │ Low │ Low │
│ Custom hook │ Medium-High │ Medium │
│ Composed hooks │ High │ Medium-High │
│ useReducer hook │ High │ High │
│ Custom provider │ Very High │ Very High │
└──────────────────┴──────────────────┴──────────────────┘Ready to practice? Challenges | Back to State Overview
Resources
Ojasa Mirai
Master AI-powered development skills through structured learning, real projects, and verified credentials. Whether you're upskilling your team or launching your career, we deliver the skills companies actually need.
Learn Deep • Build Real • Verify Skills • Launch Forward