React Hooks Essentials
React Hooks are functions that let you use various React features concisely in function components instead of the older class components, while making your code more reusable and readable. They have the following characteristics:
- They always use the
useprefix. - You use them only at the top level of a component function.
1234567891011121314// Import hooks with the use prefix import { useState, useEffect, useRef } from 'react' export default function App() { // ✅ Used at the top level useState() useEffect() useRef() // ❌ Incorrect usage if (condition) useState() while (condition) useEffect() const fn = () => useRef() }
# State
# useState
Defines reactive state inside a component.
When you call useState with an initial value, it returns a tuple (a fixed-length array) that holds a getter and a setter.
You read the value through the getter as read-only, and you can change the state only by calling the setter.
When the state changes, the component function runs again (re-renders).
123456789101112import { useState } from 'react' export default function App() { const [count, setCount] = useState(0) return ( <> <h2>Count: {count}</h2> <button onClick={() => setCount(count + 1)}>+</button> </> ) }
You can pass the setter a new value directly as an argument, or pass a callback function that receives the previous state as a parameter and returns the new state.
The callback form is useful when you update the state based on the previous state, and it guarantees the latest state, especially in asynchronous code or when state updates happen frequently.
12345// Pass the value directly setCount(count + 1) // Use a callback function setCount(count => count + 1)
# useReducer
Instead of the useState setter, which simply assigns a value, you can manage more complex state update logic.
The function that handles the state update logic is called a reducer, and you can move it outside the component and reuse it.
Write the reducer so that it receives the current state value and the argument passed when you call dispatch, and returns a new state value.
12345678910export function countReducer( state: number, action: 'increment' | 'decrement' ) { switch (action) { case 'increment': return state + 1 case 'decrement': return state - 1 default: throw new Error('Invalid action!') } }
123456789101112131415import { useReducer } from 'react' import { countReducer } from '@/reducers/count' export default function App() { const initialCount = 0 const [count, dispatch] = useReducer(countReducer, initialCount) return ( <> <p>Count: {count}</p> <button onClick={() => dispatch('increment')}>+</button> <button onClick={() => dispatch('decrement')}>-</button> </> ) }
# Effects
# useEffect
Asynchronously handles side effects that occur after a component renders.
When the component first renders, it runs the given callback once, and after that it runs the callback again only when a state included in the dependency array changes, regardless of component re-renders.
If the dependency array is empty, the callback runs only once, when the component first renders.
You can also write a cleanup function that the callback returns, and this function runs when the component is unmounted.
1234567891011121314151617181920212223242526272829303132import { useState, useEffect } from 'react' import type { User } from '@/api/user' export default function App() { const [users, setUsers] = useState<User[]>([]) const [count, setCount] = useState(0) async function fetchUsers() { const res = await fetch('https://api.heropy.dev/users') return res.json() } // Watch a state useEffect(() => { document.title = `Count: ${count}` }, [count]) // Depends on the state // Use a cleanup function useEffect(() => { const timer = setInterval(() => { setCount(prev => prev + 1) }, 1000) return () => clearInterval(timer) // Return a cleanup function }, []) // Asynchronous work useEffect(() => { fetchUsers().then(users => setUsers(users)) }, []) // ... }
The useEffect callback must not be an async function.
So to run asynchronous logic, use the .then() method as follows, or call an async function defined outside the callback.
12345678910111213141516171819// ❌ Wrong way useEffect(async () => { const users = await fetchUsers() setUsers(users) }, []) // ✅ Use the .then() method useEffect(() => { fetchUsers().then(users => setUsers(users)) }, []) // ✅ Use a separate async function async function initUsers() { const users = await fetchUsers() setUsers(users) } useEffect(() => { initUsers() }, [])
# useEffectEvent
Defines a function used inside useEffect as an Effect Event to simplify managing the dependency array.
A defined Effect Event always refers to the latest props or state, yet you don't need to include it in the useEffect dependency array, so it prevents unnecessary re-runs.
In the following example, click the button several times before 3 seconds pass after the component appears on the screen.
Without useEffectEvent, the closure captures only the count value from when the setTimeout function was called, so the initial value 0 is printed.
With useEffectEvent, however, the onLog function always refers to the latest state, so the updated count value is printed to the console.
123456789101112import { useState, useEffect, useEffectEvent } from 'react' export default function EffectEvent() { const [count, setCount] = useState(0) const onLog = useEffectEvent(() => console.log(count)) useEffect(() => { setTimeout(onLog, 3000) }, []) return <button onClick={() => setCount(c => c + 1)}>{count}</button> }
Let's look at another example.
In the following example, the onSlideChange option of the new Slide() call is an event handler that is called every time the slide changes (for example, when the slideNext method is called).
However, the onSlideChange handler always refers to 0, the initial index value from when it was declared, so it can end up using the wrong information.
123456789101112131415161718192021222324252627import { useState, useRef, useEffect, useEffectEvent } from 'react' import { analytics } from '@/lib/analytics' import { Slide } from '@/lib/slide' export default function App() { const [index, setIndex] = useState(0) const slide = useRef<Slide>(null) useEffect(() => { slide.current = new Slide({ onSlideChange: () => { analytics.track('slideChange', { index: index + 1 }) // index is always 0 } }) }, []) function handleNextSlide() { slide.current?.slideNext() setIndex(index + 1) } return ( <button onClick={handleNextSlide}> Next Slide({index}) </button> ) }
If you include index in the useEffect dependency array, you can correctly refer to the latest index value, but every time index changes, new Slide() is called unnecessarily and overwrites the existing instance (slide.current).
123456789// ... useEffect(() => { slide.current = new Slide({ onSlideChange: () => { analytics.track('slideChange', { index: index + 1 }) } }) }, [index])
So if you define the onSlideChange handler as an Effect Event with useEffectEvent, you can refer to the latest index value and also avoid unnecessary new Slide() calls.
12345678910// ... const onSlideChange = useEffectEvent(() => { analytics.track('slideChange', { index: index + 1 }) }) useEffect(() => { slide.current = new Slide({ onSlideChange }) }, [])
# useLayoutEffect
Runs the callback synchronously after DOM changes, right before the screen actually updates, which is useful for preventing visual flicker.
Unlike useEffect, it runs synchronously, so be careful: heavy work can block the UI.
useEffect:- Runs asynchronously after the component renders
- For general work such as data fetching, state subscriptions, and timers
useLayoutEffect:- Runs synchronously after DOM changes, before the screen actually updates
- For layout work such as calculating DOM size and position, and handling scrolling
12345678910111213141516171819202122import { useState, useRef, useLayoutEffect } from 'react' export default function App() { const [width, setWidth] = useState(0) const divRef = useRef<HTMLDivElement>(null) // useEffect(() => { // ❌ useLayoutEffect(() => { // ✅ if (divRef.current) { const rect = divRef.current.getBoundingClientRect() setWidth(rect.width) // 200 } }, []) return ( <div ref={divRef} style={{ width: '200px' }}> Width: {width}px </div> ) }
# useInsertionEffect
A hook designed for CSS-in-JS libraries that lets you insert styles before the DOM changes.
It is useful for building CSS-in-JS libraries such as styled-components or emotion rather than for general application development.
useEffect: After renderinguseLayoutEffect: After DOM changes, before paintuseInsertionEffect: Before DOM changes
12345678910111213141516171819202122232425262728293031323334353637import { useInsertionEffect, useState } from 'react' function useCSS(rule: string) { useInsertionEffect(() => { // Insert the CSS rule const style = document.createElement('style') style.textContent = rule document.head.appendChild(style) // On every re-render, the cleanup function runs with the previous value (style)! return () => { document.head.removeChild(style) } }, [rule]) } export default function App() { const [color, setColor] = useState('royalblue') useCSS(` .dynamic-button { background-color: ${color}; color: white; padding: 10px 30px; border: none; border-radius: 4px; } `) return ( <button className="dynamic-button" onClick={() => setColor('tomato')}> Click! </button> ) }
# Refs
# useRef
Returns a ref object that refers to a DOM element or stores a mutable value that doesn't affect rendering.
You can access the value through the ref object's current property.
123456789101112131415161718192021export default function App() { const inputRef = useRef<HTMLInputElement>(null) const countRef = useRef(0) function focusInput() { inputRef.current?.focus() } function increaseCount() { countRef.current += 1 // The value changes, but the component doesn't re-render } return ( <div> <input ref={inputRef} placeholder="Click button to focus" /> <button onClick={focusInput}>Focus</button> <button onClick={increaseCount}>Increase</button> </div> ) }
# useImperativeHandle
Customizes the ref object exposed to the parent component.
You can use it to encapsulate a component's internal implementation and expose only specific methods for outside control.
However, relying on exposed methods can increase coupling between components, so it's best to use it only for behaviors that are relatively complex or hard to handle with props (scrolling, focus, and so on).
12345678910111213141516171819202122232425262728293031323334import { useState, useRef, useImperativeHandle } from 'react' import type { RefObject } from 'react' export interface TimerHandle { start: () => void stop: () => void reset: () => void } export default function Timer({ ref }: { ref: RefObject<TimerHandle | null> }) { const [seconds, setSeconds] = useState(0) const intervalRef = useRef<number>(null) useImperativeHandle(ref, () => ({ start() { if (intervalRef.current) return // Already running intervalRef.current = window.setInterval(() => { setSeconds(prev => prev + 1) }, 1000) }, stop() { if (intervalRef.current) { clearInterval(intervalRef.current) intervalRef.current = null } }, reset() { this.stop() setSeconds(0) } })) return <div>{seconds}s</div> }
12345678910111213141516import { useRef } from 'react' import Timer from '@/components/Timer' import type { TimerHandle } from '@/components/Timer' export default function App() { const timerRef = useRef<TimerHandle>(null) return ( <> <Timer ref={timerRef} /> <button onClick={() => timerRef.current?.start()}>Start</button> <button onClick={() => timerRef.current?.stop()}>Stop</button> <button onClick={() => timerRef.current?.reset()}>Reset</button> </> ) }
# Memoization
# useMemo
Memoizes the result of an expensive calculation and recalculates it only when a value in the dependency array changes.
Because the following example uses useMemo, the getDouble function doesn't run again unless count changes.
So when text changes, the getDouble function doesn't run again.
Without useMemo, the getDouble function would run again whenever text changes.
1234export function getDouble(count: number) { console.log('getDouble ran!') return count * 2 }
12345678910111213141516171819202122import { useState, useMemo } from 'react' import { getDouble } from '@/utils' export default function App() { const [text, setText] = useState('') const [count, setCount] = useState(0) // const double = getDouble(count) // ❌ The function runs again every time the component re-renders const double = useMemo(() => getDouble(count), [count]) // ✅ return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <h1>Count: {count}</h1> <h2>Double: {double}</h2> <button onClick={() => setCount(prev => prev + 1)}>Increase</button> </> ) }
If you use React Compiler, you don't need useMemo.
In the following example, even if text changes and the component function runs again, the getDouble function doesn't run again unless the count value changes.
123456789101112131415161718192021import { useState } from 'react' import { getDouble } from '@/utils' export default function App() { const [text, setText] = useState('') const [count, setCount] = useState(0) const double = getDouble(count) // ✅ return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <h1>Count: {count}</h1> <h2>Double: {double}</h2> <button onClick={() => setCount(prev => prev + 1)}>Increase</button> </> ) }
# useCallback
Memoizes a function so that it is recreated only when a value in the dependency array changes.
It is used to prevent unnecessary re-renders and optimize performance.
Each of the following functions memoizes a different target.
memo: ComponentsuseMemo: ValuesuseCallback: Functions
In the following example, changes to the text state have nothing to do with the Button component, but when the parent component re-renders, the handleClick function is recreated too, and the Button component re-renders along with it unnecessarily.
So use useCallback to recreate the function only when the count state changes, and also wrap the Button component with the memo function so that it doesn't re-render unless its props change.
123456789101112import { memo } from 'react' import type { ButtonHTMLAttributes } from 'react' interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: React.ReactNode } function Button({ children, ...restProps }: ButtonProps) { console.log('Button component rendered!') return <button {...restProps}>{children}</button> } export default memo(Button)
123456789101112131415161718192021import { useState, useCallback } from 'react' import Button from '@/components/Button' export default function App() { const [count, setCount] = useState(0) const [text, setText] = useState('') // const handleClick = () => setCount(c => c + 1) // ❌ The function is recreated every time the component re-renders const handleClick = useCallback(() => setCount(c => c + 1), [count]) // ✅ return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <h2>Count: {count}</h2> <Button onClick={handleClick}>Click!</Button> </> ) }
If you use React Compiler, you don't need the useCallback or memo functions.
In the following example, even if text changes and the component function runs again, the handleClick function isn't recreated.
And the Button component also avoids unnecessary re-renders without the memo function.
12345678910import type { ButtonHTMLAttributes } from 'react' interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: React.ReactNode } export default function Button({ children, ...restProps }: ButtonProps) { console.log('Button component rendered!') return <button {...restProps}>{children}</button> }
1234567891011121314151617181920import { useState } from 'react' import Button from '@/components/Button' export default function App() { const [count, setCount] = useState(0) const [text, setText] = useState('') const handleClick = () => setCount(c => c + 1) // ✅ return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <h2>Count: {count}</h2> <Button onClick={handleClick}>Click!</Button> </> ) }
# useContext
With a Context object created by createContext, when a parent component provides data through the value prop of the <Context.Provider> component, child components can use that data with the useContext hook.
For more details, see React Context API Essentials.
1234import { createContext } from 'react' export type Theme = 'light' | 'dark' export const ThemeContext = createContext<Theme>('light')
123456789import { createRoot } from 'react-dom/client' import { ThemeProvider } from '@/contexts/theme' import App from '@/App' createRoot(document.getElementById('root')!).render( <ThemeContext.Provider value="dark"> <App /> </ThemeContext.Provider> )
1234567import { useContext } from 'react' import { ThemeContext } from '@/contexts/theme' export default function App() { const theme = useContext(ThemeContext) return <div>{theme} theme!</div> }
# Forms
# useId
Generates a unique ID that you can use as the value of an HTML id attribute and easily reference from other elements.
It is used in forms and accessibility attributes, and you can't use it as the key value in list rendering.
1234567891011121314151617181920import { useId } from 'react' import type { InputHTMLAttributes } from 'react' interface Props extends InputHTMLAttributes<HTMLInputElement> { label?: string } export default function TextField({ label, ...restProps }: Props) { const id = useId() return ( <> {label && <label htmlFor={id}>{label}</label>} <input id={id} {...restProps} /> </> ) }
# useActionState
Defines the data and function to use as the action (action) of a <form> component, and returns that data, the function, and the loading state.
12const action = async (initialValue, submittedData) => resultValue const [resultValue, action, loading] = useActionState(action, initialValue)
1234567891011121314151617181920212223242526272829import { useActionState } from 'react' import type { User } from '@/api/user' export default function App() { const [user, formAction, isPending] = useActionState( // Action async (_initialState: User, formData: FormData) => { await new Promise(resolve => setTimeout(resolve, 1000)) // Artificial delay const name = formData.get('name') as string return { id: 1, name } }, // Initial value {} as User ) return ( <> <form action={formAction}> <input name="name" placeholder="Name" /> <button type="submit">Submit</button> </form> {isPending && <div>Loading...</div>} {user?.name && <div>Name: {user.name}</div>} </> ) }
# useFormStatus
Returns the submission information of the parent <form> component so that child components can use it easily.
This lets you separate concerns and make components more reusable.
It is a hook provided by react-dom, not react, and it returns the following status information.
pending: Whether the form is being submitteddata: The submission resultmethod: The submission method (GET, POST, DELETE, and so on)action: The submission action (function)
12345678910111213import { useFormStatus } from 'react-dom' export default function SubmitButton() { const { pending, data, method, action } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? 'Submitting...' : 'Submit'} </button> ) }
1234567891011121314import { action } from '@/actions/user' import SubmitButton from '@/components/SubmitButton' export default function App() { return ( <> <form action={action}> <input name="name" /> <input name="age" /> <SubmitButton /> </form> </> ) }
# Transitions
# useTransition
Starting with version 18, React can prioritize work with its own scheduler through concurrent mode.
With the useTransition hook, you transition work that is heavy or not important to the user experience (actions) to a lower priority, which keeps the frame rate steady and lets other important updates go first (non-blocking updates).
In the following example, if you click the button repeatedly, the updates pile up, and after loading, the count goes up one step at a time, as many times as you clicked.
1234567891011121314151617181920212223import { useState } from 'react' function heavyWork() { return new Promise(resolve => setTimeout(resolve, 2000)) } export default function App() { const [count, setCount] = useState(0) const [isPending, setIsPending] = useState(false) async function handleClick() { setIsPending(true) await heavyWork() setIsPending(false) setCount(c => c + 1) } return ( <> <h2>Count: {isPending ? <span>Processing..</span> : count}</h2> <button onClick={handleClick}>Click!</button> </> ) }
This time, change the code to use the useTransition hook as in the following example.
Updates in a function (action) wrapped with startTransition can be interrupted by other important updates, and you can show that it's loading with the isPending state.
After the change, if you click the button repeatedly, the updates are deferred, and after loading, the last update takes priority so only the final count is displayed.
1234567891011121314151617import { useState, useTransition } from 'react' // ... export default function App() { const [count, setCount] = useState(0) // const [isPending, setIsPending] = useState(false) const [isPending, startTransition] = useTransition() async function handleClick() { startTransition(async () => { await heavyWork() setCount(c => c + 1) }) } // ... }
# useDeferredValue
Like the useTransition hook, it defers a value that isn't important to a lower-priority update.
While high-priority work runs, it keeps the previous value and defers the update to prevent the UI from blocking (non-blocking updates).
Each hook defers the following target to a lower-priority update.
useTransition: FunctionsuseDeferredValue: Values
In the following example, the SlowList component is made artificially slow to render, and useDeferredValue keeps the input responsive.
So when you type text into the input on the screen, the UI doesn't block and you can type smoothly.
Without useDeferredValue, the UI blocks while you type text into the input.
12345678910111213141516171819202122232425262728293031323334import { memo, useState, useDeferredValue } from 'react' function SlowItem({ text }: { text: string }) { const startTime = performance.now() while ((performance.now() - startTime) < 1) { // Run very slow code by doing nothing for 1ms per item. } return <li className="item">Text: {text}</li> } const SlowList = memo(function SlowList({ text }: { text: string }) { const items = Array.from({ length: 300 }, (_, i) => ( <SlowItem key={i} text={text} /> )) return <ul className="items">{items}</ul> }) export default function App() { const [text, setText] = useState('') const deferredText = useDeferredValue(text) return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <SlowList text={deferredText} /> </> ) }
# useSyncExternalStore
Lets you select external data that isn't based on React and subscribe to its changes.
When you call the useSyncExternalStore hook with a subscribe function (subscribe) and a selector function (getSnapshot) as arguments, you get the subscribed data (snapshot).
The subscribe function receives the callback to call for the subscription, and it must also return a cleanup function that cancels the subscription when the component is removed.
The selector function returns the external data to subscribe to, and React internally compares snapshots with the Object.is method to detect changes and re-render the component.
So the external data you subscribe to must be immutable.
123456const subscribeFn = (callback) => { // Subscribe with the callback.. return cleanupFn } const selectorFn = () => dataToSubscribe const subscribedData = useSyncExternalStore(subscribeFn, selectorFn)
The following example prints the screen width (window.innerWidth) every time the screen size changes.
With useEffect, the width is checked after the component renders, so 0, the initial value of the sizeA state, briefly appears on the screen.
With useSyncExternalStore, however, it subscribes to the value when rendering starts, so you get the initial value right away (blocking updates).
12345678910111213141516171819202122232425262728293031import { useState, useEffect, useSyncExternalStore } from 'react' const subscribe = (callback: () => void) => { window.addEventListener('resize', callback) return () => window.removeEventListener('resize', callback) } const getSnapshot = () => window.innerWidth export default function App() { // useEffect const [sizeA, setSizeA] = useState(0) useEffect( () => { const handleResize = () => setSizeA(window.innerWidth) handleResize() // Get the initial value window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) } }, []) // useSyncExternalStore const sizeB = useSyncExternalStore(subscribe, getSnapshot) return ( <> <h2>useEffect: {sizeA}</h2> <h2>useSyncExternalStore: {sizeB}</h2> </> ) }
You can also use it with external stores that have no reactivity (Redux, Zustand, and so on), and it prevents unnecessary re-renders.
1234567891011import { create } from 'zustand' import { combine } from 'zustand/middleware' // For type inference export const useCountStore = create( combine( { count: 0 }, set => ({ increase: () => set(state => ({ count: state.count + 1 })) }) ) )
1234567891011121314151617import { useSyncExternalStore } from 'react' import { useCountStore } from '@/stores/count' const subscribe = (callback: () => void) => useCountStore.subscribe(callback) const getSnapshot = () => useCountStore.getState().count export default function Count() { const store = useCountStore.getState() const count = useSyncExternalStore(subscribe, getSnapshot) return ( <> <h2>Count: {count}</h2> <button onClick={store.increase}>Increase Count!</button> </> ) }
# useOptimistic
Makes it easier to implement optimistic updates.
An optimistic update means updating the UI first, without waiting for the result of an asynchronous task (such as a server request).
It gives you a fast interface even when the server responds slowly, so it can greatly improve the user experience.
You must use it in a <form> component's action or in startTransition so that the update doesn't block.
123const updateFn = (previousOrInitialValue, newValue) => optimisticValue const [optimisticValue, updateFn] = useOptimistic(initialValue, updateFn) optimisticUpdateFn(newValue)
123456789101112131415161718192021222324252627282930313233343536373839import { useRef, useOptimistic, startTransition } from 'react' export default function App() { // Define the backup state const backupCountRef = useRef(0) // Define the optimistic state const [count, setCount] = useOptimistic( backupCountRef.current, (_oldCount: number, newCount: number) => newCount ) // Use it in a function that succeeds or fails async function increase(isSuccess: boolean) { startTransition(async () => { // 1. Define the value to use optimistically const newCount = count + 1 // 2. Update the UI optimistically right away setCount(newCount) // 3. Run a task that succeeds or fails await new Promise(resolve => setTimeout(resolve, 2000)) if (isSuccess) { // 4-1. On success, update the backup state too backupCountRef.current = newCount console.log('Success!') } else { // 4-2. On failure, roll the updated value back setCount(backupCountRef.current) console.log('Failure!') } }) } return ( <> <h2>Value: {count}</h2> <button onClick={() => increase(true)}>+1 (success)</button> <button onClick={() => increase(false)}>+1 (failure)</button> </> ) }
# Debugging
# useDebugValue
Adds information about a custom hook to display in React Developer Tools, for debugging during development.
Pass the value to display as the first argument of the useDebugValue hook, and if needed, pass a formatting function as the second argument.
The formatting function returns the value to display in a new form, and it is used only when React Developer Tools is actually open.
1useDebugValue(valueToDisplay, formatFn?)
1234567891011import { useDebugValue, useState } from 'react' export function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue) const increase = () => setCount(c => c + 1) useDebugValue(count) // Counter: 0 useDebugValue(count, c => `Count is ${c}`) // Counter: [0, "Count is 0"] return { count, increase } }
# Custom hooks
A custom hook is a function you build by combining hooks that React provides (useState, useEffect, and so on), and it is a powerful way to reuse state-related logic across components.
It reduces code duplication and separates concerns, making components cleaner and easier to maintain.
123456789101112131415import { useState, useCallback } from 'react' export function useInput(initialText = '') { const [value, setValue] = useState(initialText) // Input change handler const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { setValue(e.target.value) }, []) return { value, onChange } }
12345678910111213141516171819import { useInput } from '@/hooks/input' export default function App() { const idProps = useInput() const pwProps = useInput() return ( <> <input type="text" {...idProps} /> <input type="password" {...pwProps} /> </> ) }
# Writing guide
Custom hooks must always follow these rules.
- Use the
useprefix so that the hook's name is alwaysuseXXX. - Just like regular hooks, other hooks used inside a custom hook must be called only in the top-level block, not in sub-blocks such as conditionals, loops, or nested functions.
- Follow the single responsibility principle (SRP) so that each custom hook handles one clear piece of logic (for example, data fetching, tracking the window size, or managing form state).
If a hook grows too large, it's better to split it into several hooks.
# Optimization strategies
Because a custom hook encapsulates state logic inside a component, the main optimization goal is to prevent unnecessary re-renders of the component.
# Using memoization
Use memoization so that values or functions created inside a custom hook don't cause unnecessary component re-renders.
If you use React Compiler, in most cases you don't need memoization functions.
- If a custom hook returns a function, it's a good idea to wrap it with the
useCallbackhook.
This prevents a new function instance from being created every time the component re-renders, and especially when the returned function is passed as a prop to a child component, it prevents unnecessary re-renders of a child component that uses thememofunction. - When you use an expensive value inside a custom hook, it's a good idea to use
useMemo.
As long as the dependency array doesn't change, it reuses the previously calculated value and avoids unnecessary calculations.
# Managing side effects
When you use useEffect inside a custom hook, manage the dependency array clearly to prevent unnecessary re-runs.
- Include the dependencies you need so that the latest state is always used, but leave out state that doesn't need to change.
- Be careful: lint rules (warnings, errors, and so on) may force you to list every dependency even when it isn't needed.
- When a component that uses the custom hook unmounts, remove registered events in the cleanup function to prevent memory leaks.
# Using refs
Use useRef when you refer to a simple value that doesn't need to be managed as reactive state, or when you access a DOM element.
Storing and changing a value in useRef doesn't trigger a re-render, so it is useful for managing values that change often.
1234567891011121314const timerRef = useRef<number>(null) useEffect(() => { timerRef.current = setInterval(() => { console.log('Timer running..') }, 1000) return () => { if (timerRef.current !== null) { clearInterval(timerRef.current) console.log('Timer cleared!') } } }, [])
# Example: encapsulating HTTP requests
HTTP communication logic requires complex state management, such as asynchronous state, loading state, and error handling.
Moving it into a custom hook lightens the load on components and maximizes the reusability of the logic.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253import { useState, useEffect, useRef } from 'react' export function useFetch<T>(url: string, options: RequestInit = {}) { const [data, setData] = useState<T | null>(null) const [loading, setLoading] = useState(false) const [error, setError] = useState<Error | null>(null) const abortControllerRef = useRef<AbortController | null>(null) useEffect(() => { if (!url) return // Cancel the previous request, if any if (abortControllerRef.current) { abortControllerRef.current.abort() } // Create an AbortController instance for canceling requests abortControllerRef.current = new AbortController() const signal = abortControllerRef.current.signal const fetchData = async () => { // Reset loading and error setLoading(true) setError(null) try { const response = await fetch(url, { ...options, signal }) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) const result = (await response.json()) as T setData(result) } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { setError(error) } } finally { setLoading(false) } } fetchData() // Cleanup function return () => { if (abortControllerRef.current) { abortControllerRef.current.abort() } } // Use JSON.stringify to prevent an infinite loop caused by mutability! // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, JSON.stringify(options)]) return { data, loading, error } }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051import { useState } from 'react' import { useFetch } from '@/hooks/fetch' interface Movie { imdbID: string Title: string } interface SearchResponse { Search: Movie[] totalResults: `${number}` Response: 'True' | 'False' } export default function App() { const [inputText, setInputText] = useState('') const [searchText, setSearchText] = useState('') const { data, loading, error } = useFetch<SearchResponse>( searchText.trim() ? `https://omdbapi.com/?apikey=7035c60c&s=${searchText}` : '' ) const handleSearch = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault() setSearchText(inputText.trim()) } return ( <> <form onSubmit={handleSearch}> <div> <input value={inputText} onChange={event => setInputText(event.target.value)} /> <button type="submit">Search</button> </div> </form> {loading && <div>Searching...</div>} {error && <div>Error: {error.message}</div>} {data && data.Response === 'False' && <div>No results found.</div>} {data && data.Search && ( <ul> {data.Search.map(movie => ( <li key={movie.imdbID}>{movie.Title}</li> ))} </ul> )} </> ) }
# Going further with caching
For more advanced fetching strategies, data caching, and so on, you can use libraries such as SWR and TanStack Query.
Built on custom hooks, they provide advanced features such as caching, revalidation, and refetching on focus.
123456789101112131415161718192021222324252627282930313233343536import { useQuery, queryOptions, useQueryClient } from '@tanstack/react-query' import axios from 'axios' import { uniqBy } from 'lodash-es' import { create } from 'zustand' import { combine } from 'zustand/middleware' import type { SearchResponse } from './types/movie' export const useMovieStore = create( combine({ searchText: '' }, set => ({ setSearchText: (searchText: string) => set({ searchText }) })) ) export function useMovies() { const searchText = useMovieStore(state => state.searchText) const queryClient = useQueryClient() const options = queryOptions({ queryKey: ['movies', searchText], queryFn: async () => { if (searchText.length < 3) return [] const { data } = await axios<SearchResponse>( `https://omdbapi.com?apikey=7035c60c&s=${searchText}` ) return data.Search }, enabled: Boolean(searchText), staleTime: 1000 * 60 * 60, // 1h select: movies => { return uniqBy(movies, 'imdbID') } }) const result = useQuery(options) return { ...result, fetchQuery: () => queryClient.fetchQuery(options) } }
Thanks for reading to the end.
Likes and kind comments help keep this blog going!