Skip to main content

Custom hooks

In this chapter, you learn about custom hooks, which extract logic in a reusable form.

What you'll learn in this chapter

  • What a custom hook is and why you create one
  • Naming conventions and rules for custom hooks
  • Creating practical custom hooks
  • Custom hook design principles
info

This chapter uses the project created in Chapter 3. Start the development server with npm run dev and study while writing code.

What is a custom hook

A custom hook is a reusable function you build by combining React hooks (useState, useEffect, and so on).

warning

The rules of hooks: All hooks, including custom hooks, must follow these rules:

  1. Only call them at the top level: You can't call them inside loops, conditionals, or nested functions (exception: React 19's use API can be called inside conditionals and loops. See the previous chapter)
  2. Only call them inside React components or custom hooks: You can't use them in regular JavaScript functions
info

To avoid missing violations of these rules, enable eslint-plugin-react-hooks's rules-of-hooks (detects hook calls inside conditionals) and exhaustive-deps (detects missing dependencies in the dependency arrays of useEffect and the like). They're built in by default in projects created with Vite. Also, the React Compiler's detection rules (such as reporting code that the Compiler skipped optimizing) are now integrated into eslint-plugin-react-hooks.

Naming conventions for custom hooks

Always name a custom hook starting with use.

// OK
function useCounter() { /* ... */ }
function useLocalStorage() { /* ... */ }
function useFetch() { /* ... */ }

// NG: doesn't start with use
function counter() { /* ... */ }
function getLocalStorage() { /* ... */ }

Basic custom hooks

useCounter

// Custom hook: a function whose name starts with use
// You can use React hooks (useState, etc.) inside it
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)

// Define the operation functions
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
const reset = () => setCount(initialValue)

// Return the state and operation functions as an object
return { count, increment, decrement, reset }
}

// Usage
function Counter() {
// Call the custom hook and receive the return value with destructuring
const { count, increment, decrement, reset } = useCounter(10)

return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
)
}

useToggle

// A custom hook specialized for toggling a boolean value
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)

const toggle = () => setValue(prev => !prev) // flip true/false
const setTrue = () => setValue(true) // explicitly to true
const setFalse = () => setValue(false) // explicitly to false

return { value, toggle, setTrue, setFalse }
}

// Usage
function Modal() {
// { value: isOpen } receives value under the name isOpen
const { value: isOpen, toggle, setFalse } = useToggle()

return (
<>
<button onClick={toggle}>Open the modal</button>
{isOpen && (
<div className="modal">
<p>Modal content</p>
<button onClick={setFalse}>Close</button>
</div>
)}
</>
)
}

Let's try it: useToggle

Create a custom hook that toggles a boolean value. Make it return toggle, setTrue, and setFalse functions.

Hint
  1. Manage a boolean value with useState
  2. Flip it using prev => !prev in toggle
  3. Set the value explicitly with setTrue/setFalse
  4. Returning an object lets you rename via destructuring
Answer and explanation
import { useState } from 'react'
import './App.css'

function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)

const toggle = () => setValue(prev => !prev)
const setTrue = () => setValue(true)
const setFalse = () => setValue(false)

return { value, toggle, setTrue, setFalse }
}

function Modal() {
const { value: isOpen, toggle, setFalse } = useToggle()

return (
<>
<button onClick={toggle}>Open the modal</button>
{isOpen && (
<div style={{ padding: '20px', border: '1px solid #ccc', marginTop: '10px' }}>
<p>Modal content</p>
<button onClick={setFalse}>Close</button>
</div>
)}
</>
)
}

export default function App() {
return <Modal />
}

useToggle is the simplest example of a custom hook. You can flip with toggle, and set the value explicitly with setTrue/setFalse. You can rename it via destructuring, like { value: isOpen, ... }.

useInput

function useInput(initialValue = '') {
const [value, setValue] = useState(initialValue)

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}

const reset = () => setValue(initialValue)

return { value, onChange, reset }
}

// Usage
function Form() {
// reset is not a DOM attribute of <input>, so separate it
// and pass only the remaining { value, onChange } with spread
const { reset: resetName, ...nameProps } = useInput('')
const { reset: resetEmail, ...emailProps } = useInput('')

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log({ name: nameProps.value, email: emailProps.value })
resetName()
resetEmail()
}

return (
<form onSubmit={handleSubmit}>
<input {...nameProps} placeholder="Name" />
<input {...emailProps} placeholder="Email" type="email" />
<button type="submit">Submit</button>
</form>
)
}

Custom hooks with side effects

useLocalStorage

// Generics <T>: handles any type
function useLocalStorage<T>(key: string, initialValue: T) {
// Pass a function as useState's initial value (lazy initialization)
// Read localStorage only on the component's first render
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})

const setValue = (value: T | ((val: T) => T)) => {
// If a function is passed, run it; otherwise use the value as-is
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
// Save to localStorage at the same time as updating the state
localStorage.setItem(key, JSON.stringify(valueToStore))
}

// as const: return as the tuple type [value, function] (not an array)
return [storedValue, setValue] as const
}

// Usage
function Settings() {
// You can receive it as [value, update function], just like useState
const [theme, setTheme] = useLocalStorage('theme', 'light')

const handleToggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light')
}

return (
<button onClick={handleToggleTheme}>
Current: {theme}
</button>
)
}

Let's try it: useLocalStorage

Create a custom hook that syncs with local storage. Confirm that the value is retained even after reloading the page.

Hint
  1. Pass a function as useState's initial value and call localStorage.getItem
  2. Call localStorage.setItem inside the setValue function
  3. Handle any type with generics <T>
  4. Return the value as a tuple type with as const
Answer and explanation
import { useState } from 'react'
import './App.css'

function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})

const setValue = (value: T | ((val: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
localStorage.setItem(key, JSON.stringify(valueToStore))
}

return [storedValue, setValue] as const
}

function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light')

const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}

return (
<div style={{
padding: '20px',
background: theme === 'dark' ? '#333' : '#fff',
color: theme === 'dark' ? '#fff' : '#333'
}}>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle theme</button>
<p>(It is preserved even after reloading the page)</p>
</div>
)
}

export default function App() {
return <ThemeToggle />
}

We use generics <T> to handle any type. By passing a function as useState's initial value, we read localStorage only on the component's first render. The key point is returning the value as a tuple type with as const.

useDebounce

// Debounce: process only the last value of consecutive inputs
// Update debouncedValue delay milliseconds after value changes
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(() => {
// Set a timer to update the value after delay
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)

// Cleanup: cancel the previous timer when the next value arrives
return () => clearTimeout(timer)
}, [value, delay])

return debouncedValue
}

// Usage
function SearchInput() {
const [query, setQuery] = useState('')
// debouncedQuery updates after 500ms of no input
const debouncedQuery = useDebounce(query, 500)

const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}

// Call the API only when debouncedQuery changes
// → it's not called while typing (improves performance)
useEffect(() => {
if (debouncedQuery) {
console.log('Search:', debouncedQuery)
// API call, etc.
}
}, [debouncedQuery])

return (
<input
value={query}
onChange={handleQueryChange}
placeholder="Search..."
/>
)
}

Let's try it: useDebounce

Create a custom hook that debounces an input value, and use it for a search input.

Hint
  1. Manage the debounced value with useState
  2. Set setTimeout inside useEffect
  3. Call clearTimeout in the cleanup
  4. Specify value and delay in the dependency array
Answer and explanation
import { useState, useEffect } from 'react'
import './App.css'

function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)

return () => clearTimeout(timer)
}, [value, delay])

return debouncedValue
}

function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 500)
const [results, setResults] = useState<string[]>([])

const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}

useEffect(() => {
if (debouncedQuery) {
// In a real app, call an API
console.log('Running search:', debouncedQuery)
setResults([
`Search result 1 for ${debouncedQuery}`,
`Search result 2 for ${debouncedQuery}`,
`Search result 3 for ${debouncedQuery}`
])
} else {
setResults([])
}
}, [debouncedQuery])

return (
<div>
<input
value={query}
onChange={handleQueryChange}
placeholder="Search..."
/>
<p>Input value: {query}</p>
<p>After debounce: {debouncedQuery}</p>
<ul>
{results.map((result, index) => (
<li key={index}>{result}</li>
))}
</ul>
</div>
)
}

export default function App() {
return <SearchInput />
}

Debouncing is a technique that processes only the last value among consecutive inputs. By calling clearTimeout in useEffect's cleanup, you cancel the previous timer if a new value arrives within delay milliseconds. This reduces the number of API calls and improves performance.

useMediaQuery

// A custom hook that watches the state of a media query
function useMediaQuery(query: string): boolean {
// Initial value: the current result of the media query
// In SSR (running on Node.js), window doesn't exist, so guard against it
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false
return window.matchMedia(query).matches
})

useEffect(() => {
// Get the media query object with matchMedia
const mediaQuery = window.matchMedia(query)
// After mount, reflect the client result once more (resolves the gap with the SSR initial value)
setMatches(mediaQuery.matches)
// The handler for when the screen size changes
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)

// Watch for changes
mediaQuery.addEventListener('change', handler)
// Cleanup: remove the listener
return () => mediaQuery.removeEventListener('change', handler)
}, [query])

return matches // returns true/false
}
info

window.matchMedia is a browser-only API. In an environment that does SSR (server-side rendering), like Next.js, this code runs on Node.js too, so always guard with typeof window === 'undefined'. As long as you use it in a Vite-only SPA, window always exists, so it works even if you omit the guard, but getting into the habit gives you peace of mind so it doesn't break when you later move to an SSR environment.

The above is an implementation using useEffect, but a more robust option is useSyncExternalStore. When multiple components subscribe to the same media query, or when you want consistency with React's concurrent rendering, consider using it.

import { useCallback, useSyncExternalStore } from 'react'

function useMediaQuerySync(query: string): boolean {
const getSnapshot = () => window.matchMedia(query).matches
const getServerSnapshot = () => false // the fallback for SSR
// Memoize subscribe with useCallback
// (if it becomes a new function on every render, React re-subscribes each time)
const subscribe = useCallback((callback: () => void) => {
const mq = window.matchMedia(query)
mq.addEventListener('change', callback)
return () => mq.removeEventListener('change', callback)
}, [query])
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

Usage

function ResponsiveComponent() {
// true if 768px or less, false if larger
const isMobile = useMediaQuery('(max-width: 768px)')

return (
<div>
{isMobile ? (
<MobileLayout />
) : (
<DesktopLayout />
)}
</div>
)
}

Custom hook design principles

1. Single responsibility

Make a single custom hook have a single responsibility.

// OK: a single responsibility
function useWindowSize() { /* ... */ }
function useOnlineStatus() { /* ... */ }

// NG: multiple responsibilities mixed together
function useWindowAndOnline() { /* ... */ }

2. An appropriate level of abstraction

Extract common patterns and put them in a reusable form.

// A generic fetch hook
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 ignore = false

async function fetchData() {
try {
const response = await fetch(url)
const json = await response.json()
if (!ignore) setData(json)
} catch (e) {
if (!ignore) setError(e as Error)
} finally {
if (!ignore) setLoading(false)
}
}

fetchData()
return () => { ignore = true }
}, [url])

return { data, loading, error }
}

3. Type-safe with TypeScript

Leverage generics to create type-safe custom hooks.

function useArray<T>(initialValue: T[]) {
const [array, setArray] = useState(initialValue)

const push = (item: T) => setArray(prev => [...prev, item])
const remove = (index: number) => setArray(prev => prev.filter((_, i) => i !== index))
const clear = () => setArray([])

return { array, push, remove, clear, setArray }
}

Testing custom hooks

The biggest benefit of extracting logic into a custom hook is testability. Since it's independent of the component, you can check just the behavior of the logic without going through the UI rendering.

In today's React projects, the combination of Vitest + React Testing Library is the de facto standard. You can drop Vitest right into a project created with Vite.

npm install -D vitest @testing-library/react @testing-library/dom happy-dom

You can test a custom hook in isolation with renderHook. The following test code assumes you extracted the useCounter you created at the start of this chapter into src/hooks/useCounter.ts and added an export.

// src/hooks/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
it('returns the initial value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})

it('increment increases the count by 1', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
})
info

act(() => ...) is a utility for applying state updates and re-rendering together. By wrapping with it, the test side can also stay in sync with React's update timing. For component tests, the basics are to combine @testing-library/react's render / screen / userEvent to verify behavior from the user's perspective.

If you want to deepen your testing strategy further, refer to the official documentation for Vitest and Testing Library.

Summary

  • Name a custom hook starting with use
  • Extract logic by combining useState, useEffect, and so on
  • Follow the single responsibility principle
  • Be type-safe with TypeScript generics
  • When you find a common pattern, consider extracting it
  • You can test it in isolation with Vitest + React Testing Library's renderHook

In the next chapter, you learn about comparing CSS styling approaches.

Move on to UI and styling. You learn an overview of styling approaches, Tailwind CSS, and implementation patterns for Portal / Modal.