React course menu
Custom Hooks in React: Reusing Stateful Logic
Write your own React hooks: extracting repeated state and effect logic into functions that start with use, examples like useToggle, useLocalStorage, useDebounce and useOnlineStatus, how each call gets its own state, and naming rules.
On this page
When two components need the same stateful logic (state plus effects plus handlers), you can extract it into a custom hook: a function whose name starts with use and that calls other hooks. Components then share the logic, not the state.
Your first custom hook#
import { useState } from "react";
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((value) => !value);
return [on, toggle];
}
export default function App() {
const [menuOpen, toggleMenu] = useToggle();
const [showPrices, togglePrices] = useToggle(true);
return (
<div>
<button onClick={toggleMenu}>{menuOpen ? "Close" : "Open"} menu</button>{" "}
<button onClick={togglePrices}>{showPrices ? "Hide" : "Show"} prices</button>
{menuOpen && <p>Menu: Home · Shop · Contact</p>}
<p>Notebook{showPrices && " — ₹60"}</p>
</div>
);
}Each call to useToggle has its own state, exactly like calling useState twice. A custom hook shares how state works, not the state itself. (To share the same state between components, lift it up or use context.)
Rules#
- The name must start with
usefollowed by a capital letter (useToggle,useOnlineStatus). This tells React and the linter that the function follows the rules of hooks. - Call custom hooks only at the top level of components or other hooks.
- A function that does not call any hooks should be a normal function, not named
use....
useLocalStorage: state that survives a reload#
import { useEffect, useState } from "react";
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage can be unavailable (private mode, sandboxed frames): keep working in memory
}
}, [key, value]);
return [value, setValue];
}
export default function App() {
const [name, setName] = useLocalStorage("profile-name", "");
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" />
<p>Hello{name && `, ${name}`}!</p>
</div>
);
}In your own app, type a name and reload the page: it is still there. The previews on this site run in a sandboxed frame where browsers block localStorage, which is why the hook catches errors and keeps working in memory; it is good practice anyway, since storage can be unavailable in some browsers and modes.
useState(() => ...) with a function is a lazy initial value: the function runs only on the first render, so localStorage is not read on every render.
useDebounce: wait until typing stops#
Searching on every keystroke wastes requests. A debounced value only updates after the user pauses:
import { useEffect, useState } from "react";
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
const CITIES = ["Ahmedabad", "Bengaluru", "Bhopal", "Chennai", "Delhi", "Hyderabad", "Kochi", "Kolkata", "Mumbai", "Pune"];
export default function App() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
const results = CITIES.filter((c) => c.toLowerCase().includes(debouncedQuery.toLowerCase()));
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search cities" />
<p>
Typed: "{query}" · Searching for: "{debouncedQuery}"
</p>
<p>{results.join(", ") || "No cities"}</p>
</div>
);
}Every keystroke resets the timer (the effect's cleanup clears it), so the search runs only after 500 ms without typing.
useOnlineStatus: wrapping a browser API#
import { useEffect, useState } from "react";
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setOnline(true);
const goOffline = () => setOnline(false);
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", goOffline);
};
}, []);
return online;
}
export default function App() {
const online = useOnlineStatus();
return <p>{online ? "✅ Online" : "❌ Offline: changes will sync later"}</p>;
}The component reads like a sentence ("is the user online?") while the event listener details live in the hook. React also has a built-in hook, useSyncExternalStore, designed for subscribing to external data like this.
When to extract a hook#
- The same
useState+useEffectcombination appears in several components. - A component is long and a chunk of logic has a clear name (
useCart,useWindowSize). - You want to hide details (event listeners, timers, storage) behind a simple interface.
Do not extract a hook for every two lines; a good hook has a clear purpose and a clear name.
Common mistakes#
Practice#
Practice exercise
Write a useCounter(initial, step) hook that returns { count, increment, decrement, reset }, and use it twice: once for "Adults" (starting at 1) and once for "Children" (starting at 0), where children cannot go below 0.
Show hint
Inside the hook: const [count, setCount] = useState(initial), decrement uses setCount((c) => Math.max(0, c - step)), reset sets initial.
Show solution
import { useState } from "react";
function useCounter(initial = 0, step = 1) {
const [count, setCount] = useState(initial);
return {
count,
increment: () => setCount((c) => c + step),
decrement: () => setCount((c) => Math.max(0, c - step)),
reset: () => setCount(initial),
};
}
function Row({ label, counter }) {
return (
<p>
{label}: <button onClick={counter.decrement}>−</button> {counter.count}{" "}
<button onClick={counter.increment}>+</button>
</p>
);
}
export default function App() {
const adults = useCounter(1);
const children = useCounter(0);
return (
<div>
<Row label="Adults" counter={adults} />
<Row label="Children" counter={children} />
<p>Total travellers: {adults.count + children.count}</p>
</div>
);
}Key takeaway#
Next: Composition and children.
Finished this lesson?