React hooks revolutionized the way we build components by enabling function components to use state and other React features. Custom hooks take this a step further, allowing developers to extract and reuse stateful logic across components. This article explores how to create powerful, reusable custom hooks for common scenarios.
Understanding Custom Hooks
At their core, custom hooks are JavaScript functions that:
- Start with the word 'use' (important for linting and convention)
- Can call other hooks
- Extract and reuse stateful logic between components
The key insight is that hooks allow us to organize code by logical concern rather than lifecycle methods.
Building a useLocalStorage Hook
Let's start with a practical example - a hook that synchronizes state with localStorage:
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Get stored value
const readValue = () => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn('Error reading localStorage key', error);
return initialValue;
}
};
// State to store our value
const [storedValue, setStoredValue] = useState(readValue);
// 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
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to localStorage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn('Error setting localStorage key', error);
}
};
// Listen for changes to the key in other tabs/windows
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
setStoredValue(JSON.parse(event.newValue || JSON.stringify(initialValue)));
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, initialValue]);
return [storedValue, setValue];
}
Using this hook is as simple as:
function ProfileSettings() {
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>
);
}
Authentication Hook (useAuth)
Authentication is a perfect use case for custom hooks, as it's logic that's often needed across many components:
function useAuth() {
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Sign in function
const signIn = async (email, password) => {
setLoading(true);
try {
// This could be a call to your authentication service
const user = await authService.login(email, password);
setCurrentUser(user);
return user;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
};
// Sign out function
const signOut = async () => {
try {
await authService.logout();
setCurrentUser(null);
} catch (err) {
setError(err);
throw err;
}
};
// Check if user is already logged in on mount
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authService.getCurrentUser();
setCurrentUser(user);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
checkAuth();
}, []);
return {
currentUser,
loading,
error,
signIn,
signOut,
isAuthenticated: !!currentUser
};
}
This hook encapsulates all authentication-related logic and state, making it easy to use across your application:
function LoginPage() {
const { signIn, loading, error } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await signIn(email, password);
// Redirect after successful login
navigate('/dashboard');
} catch (err) {
// Error is already handled in the hook
// Login failed - error handling managed by useAuth hook
}
};
return (
<form onSubmit={handleSubmit}>
{/* Login form fields */}
{error && <p className="error">{error.message}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
Form Handling with useForm
Form handling is another common use case that can be simplified with custom hooks:
function useForm(initialValues, validate, onSubmit) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isSubmitting) {
const noErrors = Object.keys(errors).length === 0;
if (noErrors) {
onSubmit(values);
setIsSubmitting(false);
} else {
setIsSubmitting(false);
}
}
}, [errors, isSubmitting, onSubmit, values]);
const handleChange = (e) => {
const { name, value } = e.target;
setValues({
...values,
[name]: value
});
};
const handleSubmit = (e) => {
e.preventDefault();
setErrors(validate(values));
setIsSubmitting(true);
};
const handleBlur = () => {
setErrors(validate(values));
};
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
handleBlur
};
}
This hook makes form handling concise and consistent:
function ContactForm() {
const validateForm = (values) => {
let errors = {};
if (!values.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Email is invalid';
}
if (!values.message) {
errors.message = 'Message is required';
}
return errors;
};
const submitForm = async (values) => {
await apiService.sendContactForm(values);
alert('Form submitted!');
};
const {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
handleBlur
} = useForm(
{ email: '', message: '' },
validateForm,
submitForm
);
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{errors.email && <p className="error">{errors.email}</p>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={values.message}
onChange={handleChange}
onBlur={handleBlur}
/>
{errors.message && <p className="error">{errors.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</form>
);
}
API Data Fetching with useAPI
Data fetching is a common operation that benefits from hooks:
function useAPI(url) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted) {
setError(err);
setData(null);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
const refetch = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const result = await response.json();
setData(result);
setError(null);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
}, [url]);
return { data, isLoading, error, refetch };
}
Using this hook provides a clean way to handle API calls:
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useAPI(`/api/users/${userId}`);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
{/* Additional user details */}
</div>
);
}
Conclusion
Custom hooks provide a powerful pattern for sharing logic between React components. They enable better code organization, improved reusability, and more maintainable applications. By extracting common patterns like state management, form handling, authentication, and data fetching into custom hooks, you can significantly simplify your components and create a more consistent codebase.
When building custom hooks, remember these best practices:
- Keep hooks focused on a single responsibility
- Make hooks composable when possible
- Handle cleanup to prevent memory leaks
- Provide clear documentation about dependencies and behavior
- Share common hooks across your organization
By embracing custom hooks, you'll find that your React components become more concise and easier to understand, focusing on the presentation logic while the complex stateful behavior is neatly encapsulated in your custom hooks.