Your own hooks & the rules of hooks
Sharing logic – without inheriting components
A custom hook is just a function whose name starts with “use” and that uses other hooks. It shares code – never values: every component gets its own copy.
As soon as the same combination of state and effect turns up a second time, you can pull it into a function of its own. That is all a “custom hook” is: an ordinary function whose name starts with use and that uses hooks itself.
The two rules
1. Call hooks only at the top level
-> not in an if, not in a for, not after an early return
(exception: the new use from React 19)
2. Call hooks only from React functions
-> components (capital letter) or your own hooks (useXyz) The reason: React keeps track of hooks by their order, not their name. If a useState is skipped on the second pass, the whole list shifts – and your counter suddenly holds the value of the text field.
if (!user) return null;
const [name, setName] = useState(""); // sometimes skipped const [name, setName] = useState("");
if (!user) return null; Rule of thumb: all the hooks first, then the conditions. Or move the conditional logic into a component of its own.
Writing your own hook
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
} catch {
return initial;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // as const -> a tuple, not an array
}
// Using it feels just like useState:
const [theme, setTheme] = useLocalStorage("theme", "light"); If two components call useLocalStorage("theme", …), each gets its own state. They do not see each other's changes. If you want shared state you need context or an external store.
// 1) remember the previous value
export function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
useEffect(() => { ref.current = value; }, [value]);
return ref.current;
}
// 2) debounce
export function useDebounced<T>(value: T, ms = 300) {
const [late, setLate] = useState(value);
useEffect(() => {
const id = setTimeout(() => setLate(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return late;
}
// 3) a toggle
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((o) => !o), []);
return [on, toggle] as const;
} - +
The same state-plus-effect combination turns up a second time
- +
A component is so full of wiring you can no longer see the output
- +
You want to test the logic without rendering any markup
- −
Building a hook for a single line
- −
Pure computations that use no hooks – that is an ordinary function
- −
A hook that does ten unrelated things
useChatConnection(roomId) says more than useEffectWrapper. A good hook name describes what it does for you – the way useState is not called useMemoryCell.
Does it stick?
4 questions on this lesson. Wrong answers show up in your stats.
Now write it yourself
Two places in this code do the same thing: flip a boolean. Pull the logic into a useToggle hook and use it twice – the two switches have to stay independent.
- □
useTogglereturns[value, toggle] - □Both switches can be flipped separately
- □The hook is exported and called
useToggle
import { useState } from "react"; // TODO: build useToggle here and export it export function useToggle(initial = false) { return [initial, () => {}] as const; } export default function App() { const [a, toggleA] = useToggle(); const [b, toggleB] = useToggle(true); return ( <div style={{ fontFamily: "system-ui", padding: 16 }}> <button onClick={toggleA} data-testid="a">A: {String(a)}</button>{" "} <button onClick={toggleB} data-testid="b">B: {String(b)}</button> </div> ); }