If two components need to fetch data, listen to the window size, or sync with LocalStorage, beginners often copy-paste the useEffect code into both. This violates the DRY (Don't Repeat Yourself) principle.
Custom Hooks allow us to extract this stateful logic into reusable JavaScript functions.
A Custom Hook is technically just a standard JavaScript/TypeScript function. However, by convention and rule, it has specific characteristics:
use (e.g., useWindowSize, useUser). This tells React (and the Linter) that this function contains React Hooks.useState, useEffect, etc.) inside itself.The best way to write a custom hook is to write the code inside a component first, realize it is bloated, and then extract it.
Here is a component that mixes UI Logic (rendering the toggle) with State Logic (handling the boolean switch).
import { useState } from "react";
function Modal() {
// --- LOGIC START ---
const [isOpen, setIsOpen] = useState<boolean>(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen((prev) => !prev);
// --- LOGIC END ---
return (
<>
<button onClick={open}>Open Modal</button>
{isOpen && (
<div className="modal">
Hello! <button onClick={close}>X</button>
</div>
)}
</>
);
}We cut the Logic section and paste it into a new function.
// hooks/useToggle.ts
import { useState } from "react";
// 1. Define the Return Type for clarity
// Returning a tuple (array) is a common convention, like useState
type UseToggleReturn = [boolean, () => void, () => void, () => void];
export const useToggle = (initialValue: boolean = false): UseToggleReturn => {
const [value, setValue] = useState<boolean>(initialValue);
const setTrue = () => setValue(true);
const setFalse = () => setValue(false);
const toggle = () => setValue((prev) => !prev);
// Return whatever the component needs to control this logic
return [value, toggle, setTrue, setFalse];
};Now our component focuses purely on Structure, not Behavior.
import { useToggle } from "./hooks/useToggle";
function Modal() {
// The logic is now hidden behind a descriptive name
const [isOpen, toggle, open, close] = useToggle(false);
return (
<>
<button onClick={open}>Open Modal</button>
{isOpen && (
<div className="modal">
Hello! <button onClick={close}>X</button>
</div>
)}
</>
);
}A common misconception is that Custom Hooks share state between components (like a Global Store). They do not. They share logic.
function App() {
// Instance A: Has its own boolean state
const [isMenuOpen, toggleMenu] = useToggle();
// Instance B: Has its OWN boolean state.
// Clicking the modal does NOT open the menu.
const [isModalOpen, toggleModal] = useToggle();
return <div />;
}Every time we call useToggle(), it runs useState() inside it, creating a fresh memory cell in the Fiber Node for that specific component instance.\
In the real world, fetching data is messy. We need to track:
Instead of writing three useState variables in every single component that needs data, we bundle them into a useFetch hook.
Without a custom hook, every component fetching data looks like this repetitive mess:
function UserList() {
const [data, setData] = useState<User[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch("/api/users");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
setData(json);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <div>{/* Render Data */}</div>;
}This is Boilerplate. We can abstract it.
We will use TypeScript Generics (<T>) so this hook can work for any type of data (Users, Products, Comments, etc.).
// hooks/useFetch.ts
import { useState, useEffect } from "react";
// T represents the "Type" of data we expect (User[], Product, etc.)
interface FetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void; // A helper to manually reload data
}
export function useFetch<T>(url: string): FetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// We use a counter to trigger re-runs
const [trigger, setTrigger] = useState(0);
useEffect(() => {
// 1. cleanup flag to prevent updating state if component unmounts
let isMounted = true;
setLoading(true);
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Error: ${response.statusText}`);
}
const json = await response.json();
// Only update state if the component is still on screen
if (isMounted) {
setData(json);
setError(null);
}
} catch (err) {
if (isMounted) {
setError((err as Error).message);
}
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
// Cleanup function runs when component unmounts
return () => {
isMounted = false;
};
}, [url, trigger]); // Re-run if URL changes or 'refetch' is called
const refetch = () => setTrigger((prev) => prev + 1);
return { data, loading, error, refetch };
}Now, our component becomes incredibly clean and Declarative.
// Types defined elsewhere
interface User {
id: number;
name: string;
}
function UserList() {
// We pass <User[]> to tell TS what the data looks like
const { data, loading, error, refetch } = useFetch<User[]>("/api/users");
if (loading) return <p>Loading users...</p>;
if (error) return <p>Something went wrong: {error}</p>;
return (
<div>
<button onClick={refetch}>Refresh List</button>
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}In the useFetch code above, we used an isMounted boolean. A more modern and professional approach is to use the browser's AbortController to actually cancel the network request if the user leaves the page before it finishes.
useEffect(() => {
const controller = new AbortController(); // 1. Create Controller
const fetchData = async () => {
try {
// 2. Pass the signal to fetch
const res = await fetch(url, { signal: controller.signal });
const json = await res.json();
setData(json);
} catch (err) {
// 3. Ignore errors caused by cancellation
if (err.name !== "AbortError") {
setError(err.message);
}
}
};
fetchData();
// 4. Abort the request on unmount
return () => controller.abort();
}, [url]);| Concept | Description |
|---|---|
| Logic Extraction | Moving useState/useEffect out of the UI component. |
| Naming Convention | Must start with use (e.g., useForm). |
| Shared Logic | Components reuse the code/behavior. |
| Isolated State | Components do not share the data (the cake). |
| Return Value | Can be anything (Array, Object, Value). Objects are better for many return values. |
| Feature | Without Hook | With useFetch Hook |
|---|---|---|
| State | 3 separate useState calls. | 1 line of code. |
| Reusability | Copy-paste code everywhere. | Import the hook anywhere. |
| Type Safety | Manually typed every time. | Generic <T> handles dynamic types. |
| Cleanup | Often forgotten (memory leaks). | Handled centrally inside the hook. |
No. Rules of Hooks: Hooks can only be called inside:
If we try to call useToggle inside a standard helper function calculateTotal(), React will throw an Invalid Hook Call error because that helper doesn't have a Fiber Node to store the state.
[value, toggle] instead of an object { value, toggle }?It mimics useState. Returning an array allows the consumer to rename the variables easily when destructuring.
const [isModalOpen, toggleModal] = useToggle() (Renamed instantly).const { value: isModalOpen, toggle: toggleModal } = useToggle() (Renamed with verbose syntax).isMounted or AbortController?Imagine a user clicks Load Users (taking 3 seconds), but immediately clicks Home to leave the page.
Without cleanup, 3 seconds later the API responds. The function tries to call setData(json).
But the UserList component doesn't exist anymore! React will throw a warning: Can't perform a React state update on an unmounted component. This is a memory leak.
useFetch in production?For small apps, yes.
For large, professional apps, No.
Libraries like TanStack Query (React Query) or SWR do exactly this but better. They add caching, background updates, and deduplication. We build useFetch to learn how hooks work, but we use libraries for production.