Custom Hooks: When They're Your Best Friend, and When They're Just… Extra Work
Let's be real, we've all been there. Staring at a React component that started off simple, maybe a useState for a form field, a useEffect for some data fetching. Then, as features pile on, it starts to grow. Another useState for loading state, one for error messages, maybe a couple more useEffect calls for different side effects. Before you know it, you've got a component that's 200 lines long, a tangled web of stateful logic and UI concerns. It's hard to read, harder to test, and a complete nightmare to reuse.
This, my friends, is exactly the problem custom hooks were designed to solve. But like any powerful tool, it's crucial to understand when to reach for them, and perhaps more importantly, when to hold back. Because in my experience, a custom hook can either be a monumental win for maintainability or an unnecessary layer of indirection that makes things even more complex.
The Advantage: When Custom Hooks Shine Like Gold
Think of custom hooks as a way to extract stateful logic from your components and put it into a reusable, testable function. Their primary superpower is the ability to share logic between components without resorting to prop drilling, render props, or higher-order components (though those still have their place!).
1. Reusing Stateful Logic (The Obvious Win):
This is the poster child use case. I recently worked on an application with multiple forms that all needed to validate fields, track dirty state, and handle submission. Instead of duplicating useState and useEffect logic in every form component, we built a useForm hook.
import { useState, useEffect, useCallback } from 'react';
interface FormValidation<T> {
[key: string]: (value: T[keyof T]) => string | undefined;
}
const useForm = <T extends Record<string, any>>(
initialValues: T,
validations?: FormValidation<T>
) => {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Record<keyof T, string | undefined>>({});
const [isDirty, setIsDirty] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Validate all fields
const validate = useCallback(() => {
if (!validations) return {};
const newErrors: Record<keyof T, string | undefined> = {};
for (const key in values) {
if (validations[key]) {
newErrors[key] = validations[key](values[key]);
}
}
setErrors(newErrors);
return newErrors;
}, [values, validations]);
useEffect(() => {
if (isDirty) { // Only re-validate if form is dirty
validate();
}
}, [values, isDirty, validate]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
setIsDirty(true);
}, []);
const handleSubmit = useCallback(async (callback: (data: T) => Promise<void>) => {
setIsSubmitting(true);
const validationErrors = validate();
const hasErrors = Object.values(validationErrors).some(error => error !== undefined);
if (!hasErrors) {
try {
await callback(values);
} catch (err) {
console.error("Submission error:", err);
// Set a general form error or handle specifically
}
}
setIsSubmitting(false);
}, [values, validate]);
return {
values,
errors,
handleChange,
handleSubmit,
isDirty,
isSubmitting,
isValid: Object.values(errors).every(error => error === undefined) && isDirty,
reset: () => {
setValues(initialValues);
setErrors({});
setIsDirty(false);
setIsSubmitting(false);
}
};
};
Now, any form component can simply call const { values, handleChange, handleSubmit, errors } = useForm(...) and get all that complex logic ready to go. Cleaner components, less bugs, happy developers.
2. Improved Readability and Maintainability:
When components are primarily concerned with rendering and delegate their behavior to custom hooks, they become much easier to read. You can quickly scan a component and understand its purpose without getting bogged down in implementation details of data fetching or state synchronization. This also makes debugging a breeze; if there's a problem with data fetching, you know exactly which useFetch hook to examine.
3. Enhanced Testability:
Testing components with a lot of internal state can be tricky. By extracting that stateful logic into a custom hook, you can test the hook in isolation, completely decoupled from the UI. This leads to more robust and focused tests. You can test useForm's validation logic, handleChange, and handleSubmit behavior without ever rendering a single DOM element.
4. Separation of Concerns:
Custom hooks enforce a beautiful separation of concerns. Your components handle the "what" (rendering UI), while your hooks handle the "how" (managing state and side effects). This makes your codebase more modular and easier to reason about.
The Liability: When Custom Hooks Become a Burden
While custom hooks are fantastic, they're not a silver bullet. I've definitely seen (and admittedly, written) hooks that made things worse, not better.
1. Premature Abstraction (The "Everything is a Hook" Trap):
This is probably the most common pitfall. Not every useState needs to be wrapped in a custom hook. If you find yourself creating a useToggle hook that simply wraps useState(false) and returns [value, toggle], ask yourself if the overhead of a new file, import statement, and function call is truly worth it for such a trivial piece of logic. Sometimes, const [isOpen, setIsOpen] = useState(false) is perfectly fine and more explicit. The YAGNI (You Aren't Gonna Need It) principle applies here: build a hook when you see duplication, not before.
2. Over-Engineering and Unnecessary Indirection:
A custom hook should simplify, not complicate. If your hook takes 10 arguments and returns 15 things, or if it's so generic that it becomes hard to use without reading extensive documentation, you might have gone too far. Too many layers of abstraction can make it harder for new team members to trace logic, leading to confusion instead of clarity. A good hook has a clear, well-defined public API.
3. Hiding Complexity (The "Black Box" Problem):
While abstracting logic is generally good, sometimes a hook can become a black box that hides critical complexity. If a hook performs a lot of implicit side effects or relies on a deep understanding of its internal implementation, it can be difficult to debug when things go wrong. For example, a useAuth hook that does magic behind the scenes could be problematic if not clearly documented or if its behavior isn't intuitive. Always strive for transparency through clear naming and minimal dependencies.
4. Scoping and Context Issues (Especially with useEffect):
If you're not careful, a complex custom hook, especially one relying heavily on useEffect, can inadvertently introduce issues like stale closures or unexpected re-renders, making it harder to debug the core problem when it's tucked away in a hook. This often happens when dependencies arrays in useEffect or useCallback are incomplete or incorrectly managed within the hook.
The Sweet Spot: Finding Balance
So, when should you create a custom hook? Here's my rule of thumb:
- When you copy-paste stateful logic between components. This is the strongest indicator.
- When a component becomes too large and hard to read because of its internal logic, separate it.
- When you need to share non-visual, stateful behavior. Think authentication, data fetching, form handling, animation logic, managing browser APIs (like
localStorageorgeolocation). - When the logic has a clear, isolated responsibility. A hook should do one thing well.
My advice? Start simple. Write the logic directly in your component. If you find yourself needing that exact same logic in a second or third component, then it's time to refactor it into a custom hook. Give it a descriptive name (always useSomething) and ensure its API is intuitive.
Custom hooks are an incredibly powerful feature of React, elevating our ability to write cleaner, more reusable, and more maintainable applications. But like any powerful tool, they demand thoughtfulness and discipline. Use them wisely, and your codebase will thank you.
> 🚀 Read on My Blog
Top comments (0)