A custom hook is your own function that calls React’s hooks. Learn to move shared code into one, why its name starts with use, and why each component still keeps its own state.
In Parts 4 to 15 you used React’s own hooks: useState, useEffect, useRef and useReducer. In Part 13 we wrote a long Effect that loads a user from a server. Now picture three components that each need that same code. You would copy it three times. Then every bug fix must be made three times too.
A custom hook solves this. It’s a function you write yourself, and it calls React’s hooks inside. You move the shared code into it once. Then each component uses it in one line.
This part shows how to make one, how to name it, and what it shares. A custom hook shares code, but not the values the code remembers.
Try this first
Read this code. Don’t press Run yet.
import { useState } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + 1)
}
return { count, increase }
}
function Apples() {
const { count, increase } = useCounter()
return <button onClick={increase}>Apples: {count}</button>
}
function Eggs() {
const { count, increase } = useCounter()
return <button onClick={increase}>Eggs: {count}</button>
}
export default function App() {
return (
<div>
<Apples />
<Eggs />
</div>
)
}
useCounter is a normal function. It calls useState. It gives back an object with two things in it: the count, and a function that adds one.
Two components call it: Apples and Eggs. Make a guess. You click “Apples” three times. What will the “Eggs” button show?
Press Run and try it.
It shows “Eggs: 0”. Apples and Eggs run the same code, but each one has its own count. We checked this with Strict Mode on and off. Three clicks on “Apples” gave “Apples: 3” and “Eggs: 0” both times.
Keep that result in mind. We’ll come back to why it happens. First, let’s see where a custom hook comes from.
The same code in two places
Here is a common need. Two components want to know if the network is working. React’s docs use this example.
StatusBarshows “Online” or “Not connected”.SaveButtonturns itself off while the network is down.
Each one needs a piece of state, isOnline. Each one also needs an Effect that listens to the window. The browser sends an offline event when the network drops, and an online event when it comes back. Part 12 showed how to listen to window events and clean up after.
import { useEffect, useState } from 'react'
function StatusBar() {
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
function handleOnline() {
setIsOnline(true)
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return <h2>{isOnline ? 'Online' : 'Not connected'}</h2>
}
function SaveButton() {
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
function handleOnline() {
setIsOnline(true)
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return <button disabled={!isOnline}>{isOnline ? 'Save' : 'Waiting for the network'}</button>
}
export default function App() {
return (
<div>
<button onClick={() => window.dispatchEvent(new Event('offline'))}>Pretend the network drops</button>
<button onClick={() => window.dispatchEvent(new Event('online'))}>Pretend it comes back</button>
<StatusBar />
<SaveButton />
</div>
)
}
We can’t turn off your network from inside the page. So the first two buttons send pretend events. window.dispatchEvent(new Event('offline')) sends an event called offline to the window. That’s the same name the browser uses when the network really drops. Our listeners don’t check where an event came from, so they act the same way.
Run it. Press “Pretend the network drops”. Both parts change: “Not connected” and “Waiting for the network”. The Save button is turned off too. Press “Pretend it comes back”, and both go back.
One warning about the network itself. MDN, a set of web guides that many developers use, says the browser’s online status is not always right. It says you shouldn’t turn features off because of it, and should “only provide hints”. So in a real app, show a hint instead of turning the button off. We keep React’s example as it is, because the hook is the point here.
It works. But look at the code. The useState line and the whole Effect are copied, exactly the same, in both components.
Copied code has a cost. Say you find a bug in it. You must fix it in every copy, and it’s easy to miss one.
Moving the code into a custom hook
Let’s pretend React had a hook called useOnlineStatus. Then each component would need just one line: const isOnline = useOnlineStatus(). React has no such hook. But we can write it:
- Make a function called
useOnlineStatus. - Move the
useStateline and the Effect into it. - At the end, return the value the components need:
isOnline.
import { useEffect, useState } from 'react'
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
function handleOnline() {
setIsOnline(true)
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
function StatusBar() {
const isOnline = useOnlineStatus()
return <h2>{isOnline ? 'Online' : 'Not connected'}</h2>
}
function SaveButton() {
const isOnline = useOnlineStatus()
return <button disabled={!isOnline}>{isOnline ? 'Save' : 'Waiting for the network'}</button>
}
export default function App() {
return (
<div>
<button onClick={() => window.dispatchEvent(new Event('offline'))}>Pretend the network drops</button>
<button onClick={() => window.dispatchEvent(new Event('online'))}>Pretend it comes back</button>
<StatusBar />
<SaveButton />
</div>
)
}
Run it and press the buttons again. It works exactly as before.
We checked both versions with React 19.3, and counted the window’s listeners. Both versions left 2 online listeners and 2 offline listeners in place: one of each for each component. With Strict Mode on, each component also added and removed one extra pair of listeners on load, as Part 12 showed. Both removed every listener when the page was removed.
That’s all a custom hook is: a function whose name starts with use, and that calls other hooks. It’s not a new feature of React. It’s a way to put hooks you already know in one place.
The components read better now. StatusBar says what it needs: the online status. How to get it, by listening to window events, lives in one place. React’s docs say the components now describe “what they want to do”, not “how to do it”.
There’s one more gain. React’s docs point out a gap in this hook. It starts with true, even if the network is already down when the page opens. They fix it by changing the inside of useOnlineStatus to use another React hook, useSyncExternalStore. StatusBar and SaveButton don’t change at all. When code lives in one hook, a fix touches one place.
Why the name starts with use
Part 4 gave hooks two rules. Call them only at the top level. And call them only from a component, “or from your own hooks”. Now you know what “your own hooks” means.
A custom hook may call hooks. That’s because it is only ever called by a component, or by another hook, while a component renders. React’s docs say custom hooks are “supposed to only be called while a function component is rendering”. So a custom hook follows the same two rules as useState. It calls its hooks at its top level. And the component calls it at the component’s top level.
The name is how people and tools know which functions are hooks. React’s docs say a hook’s name must be use “followed by a capital letter”: useOnlineStatus, useCounter.
Who checks the name?
React itself doesn’t. We tested it. This function is called getCounter, but it calls useState:
import { useState } from 'react'
// Its name doesn't start with use, but it calls a hook.
function getCounter() {
const [count, setCount] = useState(0)
return { count, increase: () => setCount(c => c + 1) }
}
export default function App() {
const { count, increase } = getCounter()
return <button onClick={increase}>Count: {count}</button>
}
Run it and click twice. It counts to 2, and the Console stays empty. React 19.3 ran it with no warning, with Strict Mode on and off. As Part 4 showed, React finds each hook’s value by the order of the calls. It never reads names.
The name matters to the linter, the tool that reads your code and warns about likely mistakes. Part 11 met eslint-plugin-react-hooks, the rules React’s team makes for the ESLint linter. We ran it on this code. Its rules-of-hooks rule printed this error. The messages in this part are ESLint’s:
React Hook "useState" is called in function "getCounter" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".
The linter can only tell a hook by its name. Change the function’s name to useCounter, and the error goes away. We checked that too: the same code, with only the name changed, got no message. From then on, the linter also checks every place that calls useCounter.
Oxlint, the linter in the Part 10 project, has the same rule. We ran it on every example in this part. It flagged the same five examples as ESLint, and nothing else. Its messages start with the same words.
The name helps people too. When you see useSomething() inside a component, you know it may hold state or run an Effect. When you see getSomething(), you can expect that it doesn’t, as long as the code follows the naming rule.
No hooks inside? Then don’t start the name with use
Here are two functions that do the same thing:
// Avoid: a hook's name, but no hooks inside.
function useSorted(items: string[]) {
return items.slice().sort()
}
// Good: a plain function with a plain name.
function getSorted(items: string[]) {
return items.slice().sort()
}
useSorted calls no hooks. It’s a plain function with a hook’s name. React’s docs say to avoid this. If a function calls no hooks, don’t start its name with use.
The wrong name has a real cost. Hooks can’t be called inside an if. So the linter refuses this code, even though useSorted does nothing wrong:
function useSorted(items: string[]) {
return items.slice().sort()
}
export function List({ items, shouldSort }: { items: string[]; shouldSort: boolean }) {
let shown = items
if (shouldSort) {
shown = useSorted(items)
}
return <p>{shown.join(', ')}</p>
}
We ran the linter on it. It printed an error: React Hook "useSorted" is called conditionally. React Hooks must be called in the exact same order in every component render.
With the name getSorted, the same code got no message at all. A plain function can be called anywhere: inside an if, inside a loop, or in a click handler.
Custom hooks share logic, not state
Back to “Try this first”. Apples and Eggs both called useCounter(), yet each kept its own count. Here is why.
One custom hook, two components, two separate counts. Press play, or step through it.
Here are the same steps in words.
useCounteris one function. It holds steps, like “make a piece of state that starts at 0”. It doesn’t hold a number.Applesrenders and callsuseCounter(). TheuseStateinside runs as part ofApples. So React keeps that count withApples, at its place on the page, as Part 4 showed.Eggsrenders and callsuseCounter()too. ItsuseStateruns as part ofEggs, soEggsgets a separate count.- You click “Apples” three times. Only the count that belongs to
Appleschanges. Eggsstill has 0. Both components used the same steps. They never shared a number.
React’s docs say it in one line: “Custom Hooks let you share stateful logic but not state itself.” Logic here means the steps, the code. Stateful means the code uses state.
So why did StatusBar and SaveButton change together, a moment ago? Not because they shared a value. Each had its own isOnline. They changed together because both listened to the same window events.
An everyday example
A custom hook is like a printed form, such as a form to join a library. Many people fill in the same form. But each person gets their own copy and writes their own answers. Writing on your copy doesn’t change mine.
The exact version
The form idea works for everything React keeps. Each call to useState, useEffect or useRef gets its own.
But a custom hook can also reach things outside React that everyone shares. The window is one. The browser’s storage is another. Two components can both see one outside thing. That’s what happened with the online status. The state was separate. The window was shared.
The browser’s storage works the same way. Two components that both call useLocalStorage('name', ''), from the next section, save to the same place. But each has its own state. We checked: typing “Ana” in one box left the other box empty. Only after both left the page and came back did both show “Ana”.
If two components must share one value in React state, a custom hook alone won’t do it. Move the state up to their parent, as Part 9 showed. Or use context, in Part 23.
Passing values in, getting values out
A custom hook is a normal function. So it can take arguments, and it can return anything: one value, an array, or an object. useOnlineStatus takes nothing and returns one value. Here are the other two shapes.
Returning a pair
import { useState } from 'react'
function useToggle(start: boolean) {
const [on, setOn] = useState(start)
function toggle() {
setOn(o => !o)
}
return [on, toggle] as const
}
export default function App() {
const [showHelp, toggleHelp] = useToggle(false)
const [bigText, toggleBigText] = useToggle(false)
return (
<div>
<button onClick={toggleHelp}>{showHelp ? 'Hide help' : 'Show help'}</button>
<button onClick={toggleBigText}>{bigText ? 'Small text' : 'Big text'}</button>
{showHelp && <p style={{ fontSize: bigText ? 24 : 16 }}>Each button turns one thing on or off.</p>}
</div>
)
}
useToggle takes one argument: where to start, on or off. It returns a pair, an array with exactly two items. useState returns a pair too. With a pair, the code that calls the hook picks the names: [showHelp, toggleHelp], [bigText, toggleBigText].
Run it. Press “Show help”, then “Big text”. App calls useToggle twice, and the two values are separate. Showing the help doesn’t change the text size. That’s the same rule again: each call has its own state.
as const is for TypeScript. It says “this array always has these two items, in this order”. Without it, TypeScript thinks each item could be either a boolean or a function. Then onClick={toggleHelp} is a type error:
import { useState } from 'react'
function useToggle(start: boolean) {
const [on, setOn] = useState(start)
function toggle() {
setOn(o => !o)
}
return [on, toggle]
}
export default function App() {
const [showHelp, toggleHelp] = useToggle(false)
return <button onClick={toggleHelp}>{showHelp ? 'Hide help' : 'Show help'}</button>
}
TypeScript says: Type 'boolean | (() => void)' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'. The playground doesn’t check types, so this version still runs there. We checked: the button still works. An editor that checks TypeScript, like VS Code, shows the error as you type.
Returning an object
useCounter in “Try this first” returns an object: { count, increase }. With an object, the names are fixed by the hook. The caller takes what it needs by name, in any order: const { count } = useCounter().
There is no rule for which shape to use. Here is a simple guide:
- Return one value when there is only one thing, like
isOnline. - Return a pair when it works like
useState: a value, and a way to change it. - Return an object when there are three things or more. Nobody wants to remember the order of five items.
A hook that saves: useLocalStorage
localStorage is a place in the browser where a page can save text under a name, called a key. MDN says the data “is saved across browser sessions”. So it’s still there after the page reloads, or the next day.
Here is a hook that works like useState, but also saves the value:
import { useEffect, useState } from 'react'
function useLocalStorage(key: string, start: string) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key)
return saved === null ? start : saved
})
useEffect(() => {
localStorage.setItem(key, value)
}, [key, value])
return [value, setValue] as const
}
function NameBox() {
const [name, setName] = useLocalStorage('name', '')
return (
<label>
Your name: <input value={name} onChange={e => setName(e.target.value)} />
</label>
)
}
export default function App() {
const [show, setShow] = useState(true)
return (
<div>
<button onClick={() => setShow(!show)}>{show ? 'Hide' : 'Show'}</button>
{show && <NameBox />}
</div>
)
}
Here is what each part does.
useState(() => ...)is the function form from Part 4. React runs that function only on the first render (twice in Strict Mode, as Part 4 showed). It reads the saved value.getItemgivesnullwhen nothing is saved under that key yet, so then we usestart.- The Effect saves the value whenever
keyorvaluechanges. - The hook returns a pair, like
useState. SoNameBoxuses it the same way it would useuseState.
Run it. Type your name, press “Hide”, then press “Show”. Your name comes back.
Part 4 showed that a component’s state is lost when the component leaves the page. That’s still true here. But when NameBox comes back, useLocalStorage reads the saved name. We checked: with plain useState in NameBox, the box came back empty. With useLocalStorage, it came back with “Ana”.
In the playground, localStorage is not the real one. It lives in memory and is emptied on every Run. So press Run again, and the box starts empty. In a real app in a browser, the name would still be there after a reload.
One thing this small hook doesn’t handle: a key that changes. The hook reads storage only on the first render. So if key changes later, the box keeps the old value. Then the Effect saves that old value under the new key. We tested it with a key that changed from 'first' to 'second'. The box kept “Ana”, and “Ana” was saved under 'second', in place of what was there. If the storage key can change, give the component a React key with the same value: <NameBox key={storageKey} storageKey={storageKey} />. As Part 8 showed, a new key makes a new component. We checked: the box then showed the value saved under 'second'.
MDN also says the keys and values are stored as strings, which means text. To save a number or an object, turn it into text with JSON.stringify before you save it. Turn it back with JSON.parse when you read it.
Hooks that call hooks
A custom hook can call any hook. That includes React’s own hooks, like useState, useEffect and useReducer from Part 15. It also includes your own custom hooks. Here, useIsNarrow calls useWindowWidth:
import { useEffect, useState } from 'react'
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return width
}
function useIsNarrow() {
const width = useWindowWidth()
return width < 500
}
export default function App() {
const width = useWindowWidth()
const isNarrow = useIsNarrow()
return (
<div>
<p>Width: {width}px</p>
<p>{isNarrow ? 'Narrow: show the short menu.' : 'Wide: show the full menu.'}</p>
</div>
)
}
window.innerWidth is the width of the window, in pixels. A pixel is one of the tiny dots that make up the screen. When the width changes, the browser sends a resize event. The Effect listens for it, and its cleanup removes the listener.
Run it, and make your browser window narrower and wider. In the playground, the result box is its own small window, called a frame. So the number is the width of the result box, not of your whole screen. If the result box changes width, the number follows.
Our checks run the examples on a test page with no real width. So for this one, we set the width by hand. At 800 pixels, it showed “Width: 800px” and “Wide: show the full menu.” Then we set 400 and sent a resize event. It showed “Width: 400px” and “Narrow: show the short menu.”
Look at how many listeners this adds. App calls useWindowWidth once. useIsNarrow calls it again. Those are two separate calls, so there are two Effects and two listeners. We counted 2 resize listeners while the page was shown, and 0 after it was removed. If you need both values, you could call useWindowWidth once and work out width < 500 in the component.
Values you pass in: useUser(id)
In Part 13 we loaded a user in an Effect. We used an ignore flag to fix the race condition: an old, slow answer arriving after a newer one. Let’s move all of that into a custom hook. The component passes in an id. It gets back what to show: loading, an error, or the user.
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
type Request =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'done'; user: User }
// What came back, and for which id.
type Answer =
| { status: 'error'; id: number; message: string }
| { status: 'done'; id: number; user: User }
const users: User[] = [
{ id: 1, name: 'Ana' },
{ id: 2, name: 'Ben' },
{ id: 3, name: 'Cara' },
]
// A fake server, as in Part 13. It knows users 1, 2 and 3.
// User 2 is slow: 2000 ms. The others take 500 ms.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
const wait = id === 2 ? 2000 : 500
return new Promise((resolve, reject) => {
setTimeout(() => {
const user = users.find(u => u.id === id)
if (user) {
resolve(user)
} else {
reject(new Error('No user with id ' + id))
}
}, wait)
})
}
function useUser(id: number): Request {
const [answer, setAnswer] = useState<Answer | null>(null)
useEffect(() => {
let ignore = false
async function load() {
try {
const user = await fetchUser(id)
if (!ignore) {
setAnswer({ status: 'done', id, user })
}
} catch (error) {
if (!ignore) {
setAnswer({ status: 'error', id, message: String(error) })
}
}
}
load()
return () => {
ignore = true
}
}, [id])
if (answer === null || answer.id !== id) {
return { status: 'loading' }
}
return answer
}
export default function App() {
const [userId, setUserId] = useState(1)
const request = useUser(userId)
return (
<div>
<button onClick={() => setUserId(userId - 1)}>Previous</button>
<button onClick={() => setUserId(userId + 1)}>Next</button>
<h2>User {userId}</h2>
{request.status === 'loading' && <p>Loading...</p>}
{request.status === 'error' && <p>{request.message}</p>}
{request.status === 'done' && <p>Hello, {request.user.name}!</p>}
</div>
)
}
Server: someone asked for user 1
Server: someone asked for user 1
Server: someone asked for user 2
Server: someone asked for user 3
As in Part 13, this is a fake server. It’s a function that waits a bit and then answers, the way a real server would. The page can’t use the real internet.
Run it. Wait for Ana, then press Next twice, quickly. It works like Part 13’s example. But App is much shorter now. All the request code lives in useUser.
The Console shows that the server was asked for user 1 twice. That’s Strict Mode running the Effect, its cleanup, and the Effect again when the page loads, as Part 13 explained. After that, each Next click asked once.
The hook runs on every render
A custom hook’s code runs every time the component renders. It’s part of the component’s body. So, like a component, it must give the same result for the same inputs. That’s the rule from Part 11: rendering must be pure.
When you click Next, userId changes, and App renders again. It calls useUser(2). The Effect inside now sees id as 2. Since id is in its dependency array, React runs the old cleanup and then the Effect again. It asks for user 2.
Part 11 called values like this reactive: they can change from one render to the next. A reactive value passed into a custom hook stays reactive inside it. React’s docs say custom hooks “always receive the latest props and state”.
Who says “loading”?
In Part 13, the click handler set the “loading” state. A hook can’t do that. It doesn’t know about the click. It only sees a new id.
Part 13 had an answer for this case too: set “loading” at the top of the Effect. But it measured a cost. One Next click then made App render twice before the answer. The first of those renders showed “User 2” with Ana’s name under it.
So useUser does something else. It keeps the id with each answer. While rendering, it compares that id with the one it was given. If they differ, the answer is old, and the hook returns { status: 'loading' }. This works the value out while rendering, the way Part 11 suggested, instead of keeping more state.
We checked this with Strict Mode off. After one Next click, App rendered once before the answer came. That render showed “User 2” with “Loading…”, never with Ana’s name.
The race fix still works
Click Next twice, quickly. User 2 is slow, so its answer comes after Cara’s. We logged it. Ben’s answer arrived 1.5 seconds after Cara’s, with its ignore set to true. setAnswer didn’t run for it, and the page kept “Hello, Cara!”.
Now any component that needs a user gets the loading state, the error and the race fix, in one line.
When not to make a custom hook
Don’t name a hook after a moment
Some people write hooks named after a moment in a component’s life. useMount means “run this once, when the component is added”. React’s docs say to avoid these, along with useEffectOnce and useUpdateEffect. Here is why:
import { useEffect, useState } from 'react'
// Don't do this: a hook named after a moment, not a purpose.
function useMount(fn: () => void) {
useEffect(() => {
fn()
}, [])
}
function ChatRoom({ roomId }: { roomId: string }) {
useMount(() => {
console.log('connect to ' + roomId)
})
return <p>You are in the {roomId} room.</p>
}
export default function App() {
const [roomId, setRoomId] = useState('music')
return (
<div>
<button onClick={() => setRoomId('games')}>Go to games</button>
<ChatRoom roomId={roomId} />
</div>
)
}
connect to music
connect to music
This pretend chat room only prints what it does, as in Part 12.
Run it, and press “Go to games”. The page says “games”. But the Console never says “connect to games”. useMount runs fn only once, so it never sees the new roomId. Strict Mode also printed “connect to music” twice, and nothing closed the first one. With a real connection, two would now be open.
The linter can’t see the real mistake. We ran ESLint. In a plain useEffect with [] that reads roomId, it warned: React Hook useEffect has a missing dependency: 'roomId'. Inside useMount, it warned only about fn. Where ChatRoom calls useMount, it said nothing. React’s docs explain: “the linter only checks direct useEffect calls”.
The fix is a hook named after its purpose. useChatRoom(roomId) does one job: it keeps the component connected to one room.
import { useEffect, useState } from 'react'
function useChatRoom(roomId: string) {
useEffect(() => {
console.log('connect to ' + roomId)
return () => console.log('disconnect from ' + roomId)
}, [roomId])
}
function ChatRoom({ roomId }: { roomId: string }) {
useChatRoom(roomId)
return <p>You are in the {roomId} room.</p>
}
export default function App() {
const [roomId, setRoomId] = useState('music')
return (
<div>
<button onClick={() => setRoomId('games')}>Go to games</button>
<ChatRoom roomId={roomId} />
</div>
)
}
connect to music
disconnect from music
connect to music
disconnect from music
connect to games
Now “Go to games” prints disconnect from music, then connect to games. The linter got no message on this code.
Name each hook after what it does for the app: useOnlineStatus, useUser(id), useChatRoom(roomId). React’s docs want a name that is very clear. Even a person who doesn’t write code often “could have a good guess” about what it does.
Not every repeated line needs a hook
React’s docs also say that some copied code is fine. A hook that only wraps one useState call is probably not worth making. useCounter and useToggle in this part are kept that small only to teach the idea.
A good moment to think of a custom hook is when you write an Effect. Effects connect to things outside React. A hook with a clear name says what that thing is, and hides the details.
A first look at testing a hook
A custom hook lives in its own function, so you can test it on its own. React Testing Library is a set of tools for testing React code. It has a helper called renderHook, which runs your hook inside a small test component.
We tried it on useCounter. The count started at 0. After two calls to increase(), it was 2. Its docs say renderHook is mostly for people who publish hooks for others. For your own app, they suggest testing a small component that uses the hook, with render. Part 49 covers testing properly.
Common mistakes
Calling a custom hook inside an if
A custom hook follows the rules of hooks, the same as useState:
import { useState } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + 1)
}
return { count, increase }
}
export default function App() {
const [showEggs, setShowEggs] = useState(false)
const apples = useCounter()
let eggsButton = null
if (showEggs) {
const eggs = useCounter()
eggsButton = <button onClick={eggs.increase}>Eggs: {eggs.count}</button>
}
return (
<div>
<button onClick={() => setShowEggs(true)}>Show eggs</button>
<button onClick={apples.increase}>Apples: {apples.count}</button>
{eggsButton}
</div>
)
}
Run it and press “Show eggs”. React stops with the error “Rendered more hooks than during the previous render”. After the click, App called one more hook than on the render before. The Console also shows a message in red. It starts with “React has detected a change in the order of Hooks called by App”.
The linter catches this before you run it: React Hook "useCounter" is called conditionally.
The fix: move the eggs into their own component, and show that component only sometimes. A component that appears only sometimes may call hooks. It’s the hook calls inside one component that must not change.
import { useState } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + 1)
}
return { count, increase }
}
function Eggs() {
const eggs = useCounter()
return <button onClick={eggs.increase}>Eggs: {eggs.count}</button>
}
export default function App() {
const [showEggs, setShowEggs] = useState(false)
const apples = useCounter()
return (
<div>
<button onClick={() => setShowEggs(true)}>Show eggs</button>
<button onClick={apples.increase}>Apples: {apples.count}</button>
{showEggs && <Eggs />}
</div>
)
}
The wrong name
Two ways to get it wrong, both shown earlier:
- A function that calls hooks, with a name like
getCounter. React runs it, but the linter can’t check it, and it reports an error. - A function that calls no hooks, with a name like
useSorted. Now it can’t be called inside anif, for no reason.
The fix, almost always: start the name with use plus a capital letter when the function calls a hook. Otherwise, don’t. React’s docs allow one exception. A function with no hooks yet may start with use if you plan to add hooks to it later. React itself doesn’t check either way.
Expecting two components to share state
import { useState } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
return { count, increase: () => setCount(c => c + 1) }
}
function Header() {
const { count } = useCounter()
return <p>Items in the cart: {count}</p>
}
function AddButton() {
const { increase } = useCounter()
return <button onClick={increase}>Add to cart</button>
}
export default function App() {
return (
<div>
<Header />
<AddButton />
</div>
)
}
Run it and click “Add to cart”. The header stays at 0. AddButton changes its own count, which nobody shows.
The fix: keep one count in the parent, App, and pass it down, as Part 9 showed. Then Header gets count as a prop, and AddButton gets a function that changes it.
Returning a new function every render
Every time useCounter runs, it makes a new increase function. That’s fine in most places. But watch what happens when a component puts increase in an Effect’s dependency array:
import { useEffect, useState } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + 1)
}
return { count, increase }
}
export default function App() {
const { count, increase } = useCounter()
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === '+') {
increase()
}
}
console.log('add the key listener')
window.addEventListener('keydown', handleKeyDown)
return () => {
console.log('remove the key listener')
window.removeEventListener('keydown', handleKeyDown)
}
}, [increase])
return <button onClick={increase}>Count: {count}</button>
}
add the key listener
remove the key listener
add the key listener
remove the key listener
add the key listener
Run it and click the button once. The first three lines appear when the page loads, because of Strict Mode. The last two come from the click. Each click made a new increase, so React removed the listener and added it again. We counted the same two lines on every click, with Strict Mode on and off. The linter didn’t warn about it.
Here it’s only wasted work. With a real connection, the component would close and open it again on every render.
The same is true for an object or an array that the hook makes while it runs. The set function from useState is different: React keeps it the same. We checked across three renders: setCount was the same function each time, and increase was not. Part 11 warned about the same thing for objects and functions made in a component. Part 21 shows useCallback, which keeps a function the same between renders, and when you need it.
Practice
Press Edit on any example above and try these.
- In “Try this first”, add
console.log('useCounter runs')as the first line insideuseCounter. How many lines appear when the page loads? How many more after one click on “Apples”? - Give
useCounteran argument,step, and addstepinstead of 1. UseuseCounter(1)inApplesanduseCounter(5)inEggs. Click each button twice. What do they show? - In the
useUserexample, add a second component,Badge. It takes anidprop, callsuseUser(id), and shows the user’s name. Put<Badge id={userId} />under the<h2>. How many times is the server asked when the page loads? How many times after one Next click? - Fix the “Calling a custom hook inside an
if” example in a different way. CalluseCounter()for the eggs at the top ofApp, on every render. Use a condition only to choose whether to show the eggs button.
Answers
- 4 lines when the page loads. Two components call
useCounter, and Strict Mode renders each one twice. After one click on “Apples”, 2 more lines: onlyApplesrenders again, and Strict Mode doubles it. With Strict Mode off, we counted 2 and then 1. - “Apples: 2” and “Eggs: 10”. Each component passes its own
step. The full app is below. - 4 times when the page loads.
AppandBadgeeach calluseUser, and Strict Mode runs each Effect twice. After one Next click, 2 more: one for each call. Each call has its own Effect, so each one asks the server. To ask only once, calluseUseronce inApp, and pass the result toBadgeas a prop. With Strict Mode off, we counted 2 and then 2. - Now
AppcallsuseCountertwice on every render, so the order of hooks never changes. The full app is below.
Answer 2, useCounter with a step:
import { useState } from 'react'
function useCounter(step: number) {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + step)
}
return { count, increase }
}
function Apples() {
const { count, increase } = useCounter(1)
return <button onClick={increase}>Apples: {count}</button>
}
function Eggs() {
const { count, increase } = useCounter(5)
return <button onClick={increase}>Eggs: {count}</button>
}
export default function App() {
return (
<div>
<Apples />
<Eggs />
</div>
)
}
Answer 3, with Badge. Run it, wait a second, click Next once, and count the lines in the Console:
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
type Request =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'done'; user: User }
// What came back, and for which id.
type Answer =
| { status: 'error'; id: number; message: string }
| { status: 'done'; id: number; user: User }
const users: User[] = [
{ id: 1, name: 'Ana' },
{ id: 2, name: 'Ben' },
{ id: 3, name: 'Cara' },
]
// A fake server, as in Part 13. It knows users 1, 2 and 3.
// User 2 is slow: 2000 ms. The others take 500 ms.
function fetchUser(id: number): Promise<User> {
console.log('Server: someone asked for user', id)
const wait = id === 2 ? 2000 : 500
return new Promise((resolve, reject) => {
setTimeout(() => {
const user = users.find(u => u.id === id)
if (user) {
resolve(user)
} else {
reject(new Error('No user with id ' + id))
}
}, wait)
})
}
function useUser(id: number): Request {
const [answer, setAnswer] = useState<Answer | null>(null)
useEffect(() => {
let ignore = false
async function load() {
try {
const user = await fetchUser(id)
if (!ignore) {
setAnswer({ status: 'done', id, user })
}
} catch (error) {
if (!ignore) {
setAnswer({ status: 'error', id, message: String(error) })
}
}
}
load()
return () => {
ignore = true
}
}, [id])
if (answer === null || answer.id !== id) {
return { status: 'loading' }
}
return answer
}
function Badge({ id }: { id: number }) {
const request = useUser(id)
return <p>Badge: {request.status === 'done' ? request.user.name : '...'}</p>
}
export default function App() {
const [userId, setUserId] = useState(1)
const request = useUser(userId)
return (
<div>
<button onClick={() => setUserId(userId - 1)}>Previous</button>
<button onClick={() => setUserId(userId + 1)}>Next</button>
<h2>User {userId}</h2>
<Badge id={userId} />
{request.status === 'loading' && <p>Loading...</p>}
{request.status === 'error' && <p>{request.message}</p>}
{request.status === 'done' && <p>Hello, {request.user.name}!</p>}
</div>
)
}
Server: someone asked for user 1
Server: someone asked for user 1
Server: someone asked for user 1
Server: someone asked for user 1
Server: someone asked for user 2
Server: someone asked for user 2
Answer 4, both calls at the top:
import { useState } from 'react'
function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + 1)
}
return { count, increase }
}
export default function App() {
const [showEggs, setShowEggs] = useState(false)
const apples = useCounter()
const eggs = useCounter()
return (
<div>
<button onClick={() => setShowEggs(true)}>Show eggs</button>
<button onClick={apples.increase}>Apples: {apples.count}</button>
{showEggs && <button onClick={eggs.increase}>Eggs: {eggs.count}</button>}
</div>
)
}
Interview questions
Try to answer each one out loud before you open the answer.
What is a custom hook?
A function whose name starts with use followed by a capital letter, and that calls other hooks. It lets several components use the same stateful code without copying it. It’s not a special React feature. React runs it as part of the component that calls it.
A strong answer adds that the code inside runs on every render of that component. So it must be pure, like the component itself.
Two components call the same custom hook. Do they share state?
No. Each call has its own state and its own Effects. The useState inside the hook belongs to the component that called it. React’s docs say custom hooks share “stateful logic but not state itself”.
A strong answer explains why two components can still seem to agree. They might read the same outside thing, like the window’s online events. To truly share one value, lift the state up to a parent, or use context.
Why must a custom hook’s name start with use? Does React check it?
React doesn’t check it. A function named getCounter that calls useState still runs. The name is for the linter and for people. The linter’s rules-of-hooks rule only knows a function is a hook from its name. With the name, it checks that the hook calls hooks at its top level. It also checks that no caller calls it inside an if. People reading the code also know that useX() may hold state.
A strong answer adds the reverse rule. A function that calls no hooks should almost always not start with use. With that name, it can’t be called inside an if or a loop. React’s docs allow it in rare cases, such as a function you plan to add hooks to later.
When would you return an array from a custom hook, and when an object?
A pair like [value, setValue] fits when the hook works like useState. The caller picks the names, which helps when one component calls it twice. In TypeScript, add as const, or each item’s type becomes “either one”. An object fits when there are more than two things. The caller takes items by name, and the order doesn’t matter.
A custom hook takes an id, and its Effect loads data. What happens when the id changes?
The component renders again and calls the hook with the new id. The Effect inside has id in its dependency array. So React runs the old cleanup, then the Effect again, which loads the new data. The old request’s ignore flag is now true, so a slow old answer is not used. A strong answer also says where the “loading” state comes from. The hook can’t see the click. So it stores the id with each answer. An answer for another id counts as still loading.
What is wrong with a hook like useMount(fn)?
It’s named after a moment, not a purpose. It runs fn once, so it misses changes to the props and state that fn reads. The linter only checks direct useEffect calls, so it doesn’t warn where useMount is called. React’s docs say to avoid hooks named after moments like this, such as useMount, useEffectOnce and useUpdateEffect. Write a hook with a concrete purpose instead, like useChatRoom(roomId), with the right dependency array inside.
A component’s Effect depends on a function returned by a custom hook. Why might the Effect run after every render?
If the hook makes that function while it runs, it’s a new function on every render. React compares dependencies with Object.is, so a new function counts as a change. The Effect cleans up and runs again each time. The set function from useState doesn’t have this problem, because React keeps it the same. Fixes: let the Effect depend on plain values instead. Or keep the function the same with useCallback inside the hook (Part 21). A strong candidate also names useEffectEvent, which Part 11 met, for a function the Effect only calls. React’s docs say: “Wrap event handlers received by custom Hooks into Effect Events”. A strong candidate may also add that the React Compiler (Part 22) does this kind of memoization for you. Memoization means keeping a value from an earlier render, so it can be used again.
Sources
- Reusing Logic with Custom Hooks, react.dev: the online status example, the naming rule, “Custom Hooks let you share stateful logic but not state itself”, reactive values,
useSyncExternalStore, why some copied code is fine, and why to avoiduseMount. - Synchronizing with Effects and Lifecycle of Reactive Effects, react.dev: Effects, cleanup, and reactive values in the dependency array.
- Separating Events from Effects, react.dev: Effect Events and
useEffectEvent. - React Compiler, react.dev: the compiler adds memoization automatically.
- Rules of Hooks, react.dev: where hooks may be called, and why custom hooks may call hooks.
- eslint-plugin-react-hooks and its rules-of-hooks page, react.dev: what the linter checks.
- StrictMode, react.dev: why components and Effects run twice in development.
- MDN: Window.localStorage (saved across sessions, stored as strings), Window.innerWidth (also for a frame), the resize event and Navigator.onLine (not always right; give hints, don’t turn features off).
- React Testing Library API, testing-library.com:
renderHook, and the advice to preferrender. - The counts, page text, console output and errors above come from running React 19.3.0 for this post. The linter messages come from ESLint 10.12.0 with
eslint-plugin-react-hooks7.1.1, and from Oxlint 1.87.0 in the Part 10 project. TherenderHooktest used@testing-library/react16.3.3. - This part follows the Custom Hooks kata in react-katas.