Skip to main content

Custom Hooks for Reusability: Extract Component Logic

Custom hooks are JavaScript functions that call React hooks (like useState, useEffect, and useContext) to encapsulate component logic and state. Rather than duplicating the same effect or state pattern across 10 components, you extract it into a hook that any component can call. Custom hooks eliminate wrapper hell compared to HOCs and are simpler to reason about than render props. They're the modern, preferred way to share component logic in React.

I moved from duplicating data-fetching logic in every list component to writing a single useFetch hook that eliminated 2,000 lines of nearly identical code. Bugs fixed in the hook instantly fixed themselves in all consumers—productivity multiplied.

The Rules of Hooks (a Quick Review)

Before writing custom hooks, remember:

  1. Hooks must be called at the top level of a component (not inside conditionals or loops).
  2. Hooks must be called only inside React components or other custom hooks.
  3. Hook names should start with use (this signals that React rules apply).

Building Common Custom Hooks

useFetch: Data Loading with Caching

// A reusable data-fetching hook
function useFetch(url, options = {}) {
const [state, setState] = React.useState({
data: null,
isLoading: true,
error: null,
});

const [retryCount, setRetryCount] = React.useState(0);

React.useEffect(() => {
if (!url) {
setState({ data: null, isLoading: false, error: null });
return;
}

let isMounted = true;

const fetchData = async () => {
setState(prev => ({ ...prev, isLoading: true }));

try {
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
...options,
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const json = await response.json();

if (isMounted) {
setState({ data: json, isLoading: false, error: null });
}
} catch (error) {
if (isMounted) {
setState({
data: null,
isLoading: false,
error: error.message || 'Failed to fetch',
});
}
}
};

fetchData();

return () => { isMounted = false; };
}, [url, retryCount, options]);

const retry = () => setRetryCount(prev => prev + 1);

return { ...state, retry };
}

// Usage: any component can fetch data with one hook
function PostsList() {
const { data: posts, isLoading, error, retry } = useFetch('/api/posts');

if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error} <button onClick={retry}>Retry</button></div>;

return (
<ul>
{posts?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

This one hook replaces the fetch-load-error pattern in dozens of components.

useForm: Form State and Validation

// A hook for managing form state, validation, and submission
function useForm(initialValues, onSubmit, validate) {
const [values, setValues] = React.useState(initialValues);
const [errors, setErrors] = React.useState({});
const [touched, setTouched] = React.useState({});
const [isSubmitting, setIsSubmitting] = React.useState(false);

const handleChange = React.useCallback((e) => {
const { name, value, type, checked } = e.target;
const fieldValue = type === 'checkbox' ? checked : value;

setValues(prev => ({ ...prev, [name]: fieldValue }));

// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
}, [errors]);

const handleBlur = React.useCallback((e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));

// Validate on blur
if (validate) {
const error = validate(values, name);
setErrors(prev => ({ ...prev, [name]: error || null }));
}
}, [values, validate]);

const handleSubmit = React.useCallback(async (e) => {
e.preventDefault();
setIsSubmitting(true);

// Validate all fields
const newErrors = {};
if (validate) {
Object.keys(values).forEach(key => {
const error = validate(values, key);
if (error) newErrors[key] = error;
});
}

setErrors(newErrors);

if (Object.keys(newErrors).length === 0) {
try {
await onSubmit(values);
} catch (err) {
console.error('Form submission error:', err);
}
}

setIsSubmitting(false);
}, [values, onSubmit, validate]);

const reset = React.useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);

return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
setValues,
};
}

// Usage: forms in any component
function ContactForm() {
const form = useForm(
{ name: '', email: '', message: '' },
async (values) => {
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(values),
});
form.reset();
},
(values, fieldName) => {
if (fieldName === 'email' && values.email && !values.email.includes('@')) {
return 'Invalid email';
}
if (fieldName === 'message' && values.message?.length < 10) {
return 'Message must be at least 10 characters';
}
return null;
}
);

return (
<form onSubmit={form.handleSubmit}>
<input
name="name"
value={form.values.name}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="Your name"
/>
{form.touched.name && form.errors.name && <p>{form.errors.name}</p>}

<input
name="email"
type="email"
value={form.values.email}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="Your email"
/>
{form.touched.email && form.errors.email && <p>{form.errors.email}</p>}

<textarea
name="message"
value={form.values.message}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder="Your message"
/>
{form.touched.message && form.errors.message && <p>{form.errors.message}</p>}

<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Sending...' : 'Send'}
</button>
</form>
);
}

This hook unifies form handling across your entire app.

usePrevious: Tracking Previous State

// A hook that returns the previous value of a prop or state
function usePrevious(value) {
const ref = React.useRef();

React.useEffect(() => {
ref.current = value;
}, [value]);

return ref.current;
}

// Usage: detect when a prop changes
function UserProfile({ userId }) {
const previousUserId = usePrevious(userId);

React.useEffect(() => {
if (previousUserId !== userId && previousUserId !== undefined) {
console.log(`User changed from ${previousUserId} to ${userId}`);
}
}, [userId, previousUserId]);

return <div>User {userId}</div>;
}

Useful for detecting changes and triggering side effects.

useLocalStorage: Persistent State

// A hook that syncs state with localStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = React.useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});

const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};

return [storedValue, setValue];
}

// Usage: persistent theme preference
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');

return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}

Perfect for settings, user preferences, and caching.

useAsync: Simplified Async Operations

// A versatile hook for managing async operations
function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = React.useState('idle');
const [data, setData] = React.useState(null);
const [error, setError] = React.useState(null);

const execute = React.useCallback(async () => {
setStatus('pending');
setData(null);
setError(null);

try {
const result = await asyncFunction();
setData(result);
setStatus('success');
return result;
} catch (err) {
setError(err);
setStatus('error');
}
}, [asyncFunction]);

React.useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);

return { execute, status, data, error };
}

// Usage: any async operation
function DataViewer() {
const { status, data, error, execute } = useAsync(
async () => {
const res = await fetch('/api/data');
return res.json();
},
true // run immediately on mount
);

if (status === 'pending') return <div>Loading...</div>;
if (status === 'error') return <div>Error: {error.message}</div>;
if (status === 'success') return <div>{JSON.stringify(data)}</div>;

return <button onClick={execute}>Load Data</button>;
}

Custom Hooks Best Practices

PracticeWhyExample
Use useCallback for stable handlersPrevents unnecessary re-rendershandleChange = useCallback(...)
Check dependencies carefullyBugs from missing deps are subtleuseEffect(..., [url]) not []
Return organized objectsImproves usability{ data, isLoading, error } vs separate
Document required prop patternsHelps consumers use the hook correctlyMention required validator function

Key Takeaways

  • Custom hooks extract and reuse component logic by calling React hooks internally.
  • A custom hook is just a function that calls useState, useEffect, useContext, etc.—anything a component can call.
  • Common hooks to build: useFetch (data loading), useForm (form handling), usePrevious (detect changes), useLocalStorage (persistent state).
  • Custom hooks eliminate wrapper component overhead (compared to HOCs and render props) and produce cleaner, more readable component code.
  • Always follow the rules of hooks: call at top level, only in components or other custom hooks, and name with use prefix.

Frequently Asked Questions

Can I call one custom hook inside another?

Yes, absolutely. Custom hooks can call other custom hooks. This is how you build complex logic from simpler pieces.

Should I use custom hooks or a state management library?

For component-local or small-to-medium scope logic, custom hooks are perfect. For complex app-wide state with many operations, consider Redux, Zustand, or Jotai.

What's the difference between a custom hook and a helper function?

A custom hook calls React hooks internally (useState, useEffect, useContext); a helper function is just JavaScript. Hooks have rules; helpers don't. Use hooks for logic that depends on React state or lifecycle.

Can I test custom hooks?

Yes. Use renderHook from @testing-library/react. Example: const { result } = renderHook(() => useForm(...)).

What about TypeScript with custom hooks?

Type the parameters and return value. Example: function useForm<T>(initial: T, onSubmit: (values: T) => Promise<void>): FormState<T> { ... }.

Further Reading