After leading development teams for over half a decade, I've seen firsthand how small architectural decisions can dramatically impact productivity. Nothing demonstrates this better than our team's journey with custom React hooks.
When we first adopted React at scale, we were building a complex SaaS platform with dozens of interconnected features. Our codebase quickly became a labyrinth of repeated logic, inconsistent state management, and bespoke solutions to common problems. Sound familiar?
Today, I want to share the custom hooks that fundamentally changed how we approach React development—hooks that not only cleaned up our codebase but transformed how our teams collaborate and ship features.
The Problem with Traditional Approaches
Before diving into specific hooks, let's consider a scenario most React developers face: fetching and managing API data. Here's how this might traditionally look:
function ProductList() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchProducts = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/products');
const data = await response.json();
setProducts(data);
setError(null);
} catch (err) {
setError(err.message);
setProducts([]);
} finally {
setIsLoading(false);
}
};
fetchProducts();
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
What's wrong with this approach? Consider the questions:
- What happens if we need identical data fetching logic in multiple components?
- How do we handle refetching, caching, or pagination consistently?
- What about managing loading and error states across complex UI workflows?
This approach clearly doesn't scale as your application grows. Enter custom hooks.
Hook #1: useFetch — Transforming Data Fetching
The first hook that changed everything for us was useFetch
. Here's the implementation:
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [refetchIndex, setRefetchIndex] = useState(0);
// Cache control
const { cache = true, dependencies = [] } = options;
const refetch = useCallback(() => {
setRefetchIndex(prev => prev + 1);
}, []);
useEffect(() => {
// Don't fetch if no URL provided
if (!url) return;
let isCancelled = false;
setIsLoading(true);
const fetchData = async () => {
try {
const response = await fetch(url, options.fetchOptions);
if (!response.ok) {
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (!isCancelled) {
setData(result);
setError(null);
}
} catch (err) {
if (!isCancelled) {
setError(err.message);
setData(null);
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
fetchData();
// Cleanup function to handle component unmounting
return () => {
isCancelled = true;
};
}, [url, refetchIndex, ...dependencies]);
return { data, isLoading, error, refetch };
}
Now our component becomes:
function ProductList() {
const { data: products, isLoading, error } = useFetch('/api/products');
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
{products?.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
The transformation is remarkable. Not only is our component significantly cleaner, but we've also:
- Abstracted away all the fetching, loading, and error handling logic
- Added request cancellation to prevent memory leaks
- Included a
refetch
function for manual refreshes - Added dependency tracking to allow refetching based on state changes
This hook alone eliminated thousands of lines of duplicated code across our application.
Hook #2: useLocalStorage — Persistent State Management
Another game-changer was our useLocalStorage
hook. The problem? React's state is ephemeral—refresh the page, and it's gone. While solutions like Redux persist to localStorage, they add complexity for simple cases.
function useLocalStorage(key, initialValue) {
// State to store our value
const [storedValue, setStoredValue] = useState(() => {
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.error(error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that
// persists the new value to localStorage.
const setValue = value => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
// A more advanced implementation would handle the error case
console.error(error);
}
};
return [storedValue, setValue];
}
Using this hook is as simple as replacing useState
:
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<select
value={theme}
onChange={e => setTheme(e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}
Now our theme preference persists across page refreshes—no additional libraries required. This pattern proved invaluable for user preferences, form data, and application state that needed to survive navigation.
Hook #3: useMediaQuery — Responsive Design in React
Responsive design has traditionally been the domain of CSS, but what about responsive behavior? Our useMediaQuery
hook bridged this gap:
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
// Update the state initially
setMatches(media.matches);
// Define callback for media query changes
const listener = (event) => {
setMatches(event.matches);
};
// Add the callback as a listener
media.addEventListener('change', listener);
// Remove the listener on cleanup
return () => {
media.removeEventListener('change', listener);
};
}, [query]);
return matches;
}
This simple hook enables responsive behavior in component logic:
function ResponsiveNavigation() {
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<nav>
{isMobile ? (
<MobileMenu />
) : (
<DesktopMenu />
)}
</nav>
);
}
This hook eliminated countless bugs related to window resizing and hydration mismatches between server and client. Beyond that, it allowed us to create truly adaptive UIs that respond not just in appearance but in functionality to the user's context.
Hook #4: useDebounce — Performance Optimization
Real-time search, form validation, and API calls during typing can create performance issues. Our useDebounce
hook tackled this elegantly:
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set debouncedValue to value after specified delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes or component unmounts
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
This hook transformed our search components:
function SearchBar() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { data, isLoading } = useFetch(
debouncedSearchTerm ? `/api/search?q=${debouncedSearchTerm}` : null
);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
{isLoading && <span>Loading...</span>}
{data && (
<ul>
{data.results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)}
</div>
);
}
This pattern eliminated unnecessary network requests and prevented UI jank during typing. After implementing this hook, our search performance dramatically improved, and our backend servers thanked us for the reduced load.
Hook #5: useForm — Simplified Form Management
Forms in React can be verbose and error-prone. While libraries like Formik and React Hook Form exist, for our specific needs, we created a lighter-weight useForm
hook:
function useForm(initialValues, onSubmit, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset form to initial values
const resetForm = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
// Update form values
const handleChange = useCallback((event) => {
const { name, value } = event.target;
setValues(prevValues => ({
...prevValues,
[name]: value
}));
}, []);
// Mark field as touched on blur
const handleBlur = useCallback((event) => {
const { name } = event.target;
setTouched(prevTouched => ({
...prevTouched,
[name]: true
}));
}, []);
// Validate form on submit
const handleSubmit = useCallback(async (event) => {
if (event) event.preventDefault();
// Mark all fields as touched
const allTouched = Object.keys(initialValues).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
setTouched(allTouched);
// Validate if function provided
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
// Don't submit if there are errors
if (Object.keys(validationErrors).length > 0) return;
}
setIsSubmitting(true);
try {
await onSubmit(values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
}, [initialValues, onSubmit, validate, values]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
resetForm
};
}
This hook transformed our form components:
function SignupForm() {
const validate = (values) => {
const errors = {};
if (!values.email) {
errors.email = 'Required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Invalid email';
}
if (!values.password) {
errors.password = 'Required';
} else if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
return errors;
};
const handleSignup = async (values) => {
// API call to register user
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
};
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit
} = useForm(
{ email: '', password: '' },
handleSignup,
validate
);
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && (
<div className="error">{errors.email}</div>
)}
</div>
<div>
<label>Password</label>
<input
type="password"
name="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && (
<div className="error">{errors.password}</div>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}
This approach drastically simplified our form handling while providing validation, touched states, and submission handling in a clean, reusable pattern.
The Impact on Our Development Process
These hooks didn't just clean up our code—they fundamentally changed how we think about and build React applications:
-
Standardized Patterns: Teams across different projects now speak the same language. When a developer says "we'll use useFetch here," everyone understands the implementation details.
-
Faster Onboarding: New team members can focus on business logic rather than reinventing utility functions. Our onboarding time decreased by roughly 40%.
-
Reduced Bugs: By centralizing common functionality, we fix bugs once instead of hunting them down across multiple implementations.
-
Improved Performance: Hooks like useDebounce and carefully optimized data fetching improved our application performance significantly.
-
Better Testing: With logic extracted into hooks, we can unit test these utilities separately from the UI components that use them.
Creating Your Own Hook Library
Based on our experience, here are the steps to building your own custom hook library:
-
Identify Repetitive Patterns: Look for code you're writing again and again across components.
-
Start Small: Begin with one or two hooks that solve immediate pain points.
-
Document Well: Each hook should have clear documentation on inputs, outputs, and behavior.
-
Test Thoroughly: Hooks are pure JavaScript functions and should be unit tested.
-
Share Knowledge: Build a component library or documentation site that showcases how to use these hooks.
-
Iterate Based on Feedback: As developers use your hooks, gather feedback and refine them.
Advanced Hook Composition
The true power of hooks comes from composition. Consider this advanced example that combines multiple hooks:
function ProductSearch() {
// Responsive state
const isMobile = useMediaQuery('(max-width: 768px)');
// Form state
const { values, handleChange } = useForm({ search: '' });
// Debounced search term
const debouncedSearch = useDebounce(values.search, 400);
// Fetch results based on debounced search
const { data, isLoading } = useFetch(
debouncedSearch ? `/api/products/search?q=${debouncedSearch}` : null
);
// Persist view preference
const [viewMode, setViewMode] = useLocalStorage('productViewMode', 'grid');
return (
<div className="product-search">
<div className="search-controls">
<input
name="search"
value={values.search}
onChange={handleChange}
placeholder={isMobile ? "Search" : "Search products..."}
className={isMobile ? "compact-input" : "full-input"}
/>
<button
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
aria-label="Toggle view"
>
{viewMode === 'grid' ? 'List View' : 'Grid View'}
</button>
</div>
{isLoading ? (
<LoadingSpinner />
) : (
<ProductDisplay
products={data?.products || []}
viewMode={viewMode}
layout={isMobile ? 'vertical' : 'horizontal'}
/>
)}
</div>
);
}
This component combines five custom hooks into a cohesive, responsive, and performant product search feature with just a few lines of JSX.
Conclusion: Beyond the Hooks
Custom hooks transformed our development process by enforcing consistency, improving performance, and allowing us to focus on product features rather than utility functions. But perhaps their most important impact was cultural: they established a common vocabulary and set of best practices across our organization.
As you evaluate your own React codebase, ask yourself: What patterns do you find yourself repeating? Which pieces of state management are inconsistent across components? What functionality could be abstracted into reusable hooks?
The journey to a more maintainable codebase begins with recognizing these patterns and taking small, deliberate steps toward abstraction. Start with one hook today, and you might be surprised how quickly it spreads throughout your application.
I'd love to hear about your experiences: What custom hooks have transformed your development process, and how did they change the way your team thinks about React architecture?