Custom Hooks: Reusable Logic
Custom Hooks: Reusable Logic
Custom hooks are the best feature React added in recent years. They let you extract stateful logic from components into standalone functions — reusable, testable, and composable. Any time you see the same useState + useEffect pattern in two components, a custom hook is the right abstraction.
The Rule: It's Just a Function
A custom hook is a regular JavaScript function whose name starts with use and that calls other hooks inside it. That's it. No magic, no framework — just a naming convention that React uses to enforce the Rules of Hooks.
// This is a custom hook
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}
// Usage — same logic, no duplication
function Header() {
const { width } = useWindowSize();
return <nav className={width < 768 ? "mobile-nav" : "desktop-nav"}>...</nav>;
}
function Chart() {
const { width, height } = useWindowSize();
return <canvas width={width} height={height} />;
}
useFetch — Data Fetching Hook
Stop writing the same loading/error/data pattern in every component:
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Clean component — no fetch logic visible
function CourseList() {
const { data: courses, loading, error } = useFetch<Course[]>("/api/courses");
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error.message} />;
if (!courses) return null;
return <CourseGrid courses={courses} />;
}
useLocalStorage — Persistent State
function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
function setValue(value: T | ((prev: T) => T)) {
try {
const valueToStore = value instanceof Function ? value(stored) : value;
setStored(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (err) {
console.error(err);
}
}
return [stored, setValue] as const;
}
// Works exactly like useState, but persists to localStorage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
return (
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Current: {theme}
</button>
);
}
useDebounce — Delay Rapid Changes
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function SearchPage() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// Only fetches after typing stops for 300ms
const { data: results } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search courses..."
/>
<ResultsList results={results} />
</div>
);
}
useIntersectionObserver — Infinite Scroll / Lazy Loading
function useIntersectionObserver(
ref: React.RefObject<Element>,
options?: IntersectionObserverInit
) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options]);
return isVisible;
}
// Lazy-load images when they scroll into view
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(ref, { rootMargin: "100px" });
return (
<div ref={ref} className="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
{isVisible && (
<img src={src} alt={alt} className="w-full h-full object-cover" />
)}
</div>
);
}
useForm — Form State Management
function useForm<T extends Record<string, string>>(initialValues: T) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
setErrors(prev => ({ ...prev, [name]: undefined }));
}
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
}
function setError(field: keyof T, message: string) {
setErrors(prev => ({ ...prev, [field]: message }));
}
function reset() {
setValues(initialValues);
setErrors({});
setTouched({});
}
return {
values,
errors,
touched,
handleChange,
handleBlur,
setError,
reset,
};
}
// Usage
function LoginForm() {
const { values, errors, touched, handleChange, handleBlur, setError } = useForm({
email: "",
password: "",
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!values.email.includes("@")) {
setError("email", "Valid email required");
return;
}
await login(values.email, values.password);
}
return (
<form onSubmit={handleSubmit}>
<input
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && <p className="error">{errors.email}</p>}
{/* ... */}
</form>
);
}
usePrevious
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function PriceTracker({ price }: { price: number }) {
const prevPrice = usePrevious(price);
const direction = prevPrice !== undefined && price > prevPrice ? "up" : "down";
return <span className={direction === "up" ? "text-green-600" : "text-red-600"}>${price}</span>;
}
The Custom Hook Mindset
When you find yourself adding the same useState + useEffect block in a second component, that's the moment to extract a hook. Ask:
- What's the intent? Name the hook after it:
useFetch,useDebounce,useAuth,useCart - What state and effects belong together? Keep them in the hook
- What does the consumer need? Return just that — values, setters, and callbacks
Good custom hooks have a clear responsibility, a descriptive name, and return exactly what's needed — nothing more.
Next lesson: React.memo and avoiding re-renders — understanding React's rendering model and when to optimize.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises