React Hooks 핵심 정리
React Hooks(훅)는 기존의 클래스형 컴포넌트 대신 함수형 컴포넌트에서 다양한 React 기능을 코드의 재사용성과 가독성을 높이면서 간결하게 사용할 수 있도록 도와주는 함수를 말하며, 다음과 같은 특징이 있습니다.
- 항상
use접두사를 사용합니다. - 컴포넌트 함수의 최상위 레벨에서만 사용합니다.
1234567891011121314// use 접두사의 Hooks 가져오기 import { useState, useEffect, useRef } from 'react' export default function App() { // ✅ 최상위 레벨에서 사용 useState() useEffect() useRef() // ❌ 잘못된 사용 if (condition) useState() while (condition) useEffect() const fn = () => useRef() }
# 상태
# useState
컴포넌트 내에서 반응형 상태(Reactive State)를 정의합니다.useState를 초깃값과 함께 호출하면, 게터(Getter)와 세터(Setter)를 가지는 고정 길이 배열(Tuple)을 반환합니다.
게터를 통해 값을 읽기전용으로 얻고, 세터 호출을 통해서만 상태를 변경할 수 있습니다.
상태가 변경되면 해당 컴포넌트 함수가 다시 실행(리렌더링)됩니다.
123456789101112import { useState } from 'react' export default function App() { const [count, setCount] = useState(0) return ( <> <h2>Count: {count}</h2> <button onClick={() => setCount(count + 1)}>+</button> </> ) }
세터는 새로운 값을 직접 인수로 전달하거나, 이전 상태를 매개변수로 받아 새로운 상태를 반환하는 콜백 함수를 전달할 수 있습니다.
콜백 방식은 이전 상태를 기반으로 상태를 업데이트할 때 유용하며, 특히 비동기 처리나 상태 업데이트가 빈번하게 발생할 때 최신 상태를 보장할 수 있습니다.
12345// 값을 직접 전달 setCount(count + 1) // 콜백 함수 사용 setCount(count => count + 1)
# useReducer
단순히 값을 할당하는 useState의 세터(Setter) 대신, 더 복잡한 상태 업데이트 로직을 관리할 수 있습니다.
상태 업데이트 로직을 처리하는 함수를 리듀서(Reducer)라고 부르며, 이를 컴포넌트 외부로 분리하고 재사용할 수 있습니다.
리듀서는 현재의 상태 값과 디스패치(Dispatch)를 호출할 때 전달하는 인수를 받아 새로운 상태 값을 반환하도록 작성합니다.
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> </> ) }
# 이펙트
# useEffect
컴포넌트가 렌더링된 이후에 발생하는 사이드 이펙트(Side Effect)를 비동기적으로 처리합니다.
컴포넌트가 최초 렌더링될 때 지정된 콜백(Callback Function)을 한 번 실행하고, 이후 컴포넌트 리렌더링과 별개로 의존성 배열(Dependency Array)에 포함된 상태가 변경될 때만 콜백을 다시 실행합니다.
의존성 배열이 비어있으면, 컴포넌트가 최초 렌더링될 때 한 번만 콜백을 실행합니다.
또한 콜백에서 반환하는 클린업 함수(Cleanup Function)를 작성할 수 있으며, 이 함수는 컴포넌트가 연결 해제(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() } // 상태를 감시 useEffect(() => { document.title = `Count: ${count}` }, [count]) // 상태에 의존 // 클린업 함수 활용 useEffect(() => { const timer = setInterval(() => { setCount(prev => prev + 1) }, 1000) return () => clearInterval(timer) // 클린업 함수 반환 }, []) // 비동기 처리 useEffect(() => { fetchUsers().then(users => setUsers(users)) }, []) // ... }
useEffect의 콜백은 비동기 함수가 아니어야 합니다.
따라서 비동기 로직을 실행하려면 다음과 같이 .then() 메소드를 사용하거나 외부의 비동기 함수를 호출해야 합니다.
12345678910111213141516171819// ❌ 잘못된 방법 useEffect(async () => { const users = await fetchUsers() setUsers(users) }, []) // ✅ .then() 메소드 사용 useEffect(() => { fetchUsers().then(users => setUsers(users)) }, []) // ✅ 별도 비동기 함수 사용 async function initUsers() { const users = await fetchUsers() setUsers(users) } useEffect(() => { initUsers() }, [])
# useEffectEvent
useEffect 내부에서 사용하는 함수를 Effect Event로 정의해, 의존성 배열 관리를 단순화합니다.
정의된 Effect Event는 항상 최신 Props 혹은 State를 참조하면서도 useEffect의 의존성 배열에 포함하지 않아도 되므로, 불필요한 재실행을 방지할 수 있습니다.
다음 예제에서 컴포넌트가 화면에 출력된 후 3초가 지나기 전에 버튼을 여러 번 클릭합니다.useEffectEvent를 사용하지 않으면, 클로저(Closure)로 인해 setTimeout 함수가 호출될 때의 count 값만 참조되어 초깃값인 0이 출력됩니다.
하지만 useEffectEvent를 사용하면, onLog 함수는 항상 최신 상태를 참조하게 되므로 콘솔에는 업데이트된 count 값이 출력됩니다.
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> }
또 다른 예제를 살펴봅시다.
다음 예제에서 new Slide() 호출의 onSlideChange 옵션은 슬라이드가 바뀔 때(slideNext 메소드 호출 등)마다 호출되는 이벤트 핸들러입니다.
하지만 onSlideChange 핸들러는 선언될 때의 index 초깃값인 0을 항상 참조하므로, 잘못된 정보를 사용하게 될 수 있습니다.
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는 항상 0 } }) }, []) function handleNextSlide() { slide.current?.slideNext() setIndex(index + 1) } return ( <button onClick={handleNextSlide}> Next Slide({index}) </button> ) }
일단 useEffect의 의존성 배열에 index를 포함하면 정상적으로 최신의 index 값을 참조할 수 있지만, index 값이 변경될 때마다 new Slide()를 불필요하게 호출하며 기존 인스턴스(slide.current)를 덮어쓰게 됩니다.
123456789// ... useEffect(() => { slide.current = new Slide({ onSlideChange: () => { analytics.track('slideChange', { index: index + 1 }) } }) }, [index])
그래서 onSlideChange 핸들러를 useEffectEvent를 사용해 Effect Event로 정의하면, 최신의 index 값을 참조하면서도 불필요한 new Slide() 호출도 방지할 수 있습니다.
12345678910// ... const onSlideChange = useEffectEvent(() => { analytics.track('slideChange', { index: index + 1 }) }) useEffect(() => { slide.current = new Slide({ onSlideChange }) }, [])
# useLayoutEffect
DOM 변경 후, 화면이 실제로 업데이트되기 직전에 동기적으로 콜백을 실행하며, 시각적인 깜빡임 방지에 유용합니다.useEffect와 다르게 동기적으로 실행되므로, 무거운 작업 시 UI가 블로킹될 수 있으니 주의해야 합니다.
useEffect:- 컴포넌트 렌더링 후에 비동기적으로 실행
- 데이터 패칭, 상태 구독, 타이머 등의 일반적인 처리
useLayoutEffect:- DOM 변경 후, 화면 실제 업데이트 전에 동기적으로 실행
- DOM 크기, 위치 계산, 스크롤 처리 등의 레이아웃 처리
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}px </div> ) }
# useInsertionEffect
CSS-in-JS 라이브러리를 위해 설계된 훅으로, DOM이 변경되기 전에 스타일을 삽입할 수 있습니다.
일반적인 애플리케이션 개발보다는, styled-components나 emotion 같은 CSS-in-JS 라이브러리 개발에 유용합니다.
useEffect: 렌더링 후useLayoutEffect: DOM 변경 후 페인트 전useInsertionEffect: DOM 변경 전
12345678910111213141516171819202122232425262728293031323334353637import { useInsertionEffect, useState } from 'react' function useCSS(rule: string) { useInsertionEffect(() => { // CSS 규칙을 삽입 const style = document.createElement('style') style.textContent = rule document.head.appendChild(style) // 리렌더링할 때마다 이전 값(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')}> 클릭! </button> ) }
# 참조
# useRef
DOM 요소를 참조하거나 렌더링에 영향을 주지 않는 변경 가능한 값(Mutable Value)을 저장하는 참조 객체를 반환합니다.
참조 객체의 current 속성을 통해 값에 접근할 수 있습니다.
123456789101112131415161718192021export default function App() { const inputRef = useRef<HTMLInputElement>(null) const countRef = useRef(0) function focusInput() { inputRef.current?.focus() } function increaseCount() { countRef.current += 1 // 값은 변경되지만, 컴포넌트는 리렌더링되지 않음 } return ( <div> <input ref={inputRef} placeholder="Click button to focus" /> <button onClick={focusInput}>포커스</button> <button onClick={increaseCount}>증가</button> </div> ) }
# useImperativeHandle
부모 컴포넌트에게 노출할 참조 객체를 사용자 정의합니다.
컴포넌트의 내부 구현을 캡슐화하고 특정 메소드만 외부에 노출해 제어하기 위해 사용할 수 있습니다.
다만 노출된 메소드에만 의존하면 컴포넌트 간 결합도가 높아질 수 있으므로, Props로 처리하기에 비교적 복잡하거나 어려운 동작(스크롤, 포커스 등)에 제한적으로 사용하는 것이 좋습니다.
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 // 이미 실행 중 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}초</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()}>시작</button> <button onClick={() => timerRef.current?.stop()}>중지</button> <button onClick={() => timerRef.current?.reset()}>초기화</button> </> ) }
# 메모이제이션
# useMemo
비용이 많이 드는 연산의 결과를 메모이제이션(Memoization)하고 의존성 배열의 값이 변경될 때만 다시 계산합니다.
다음 예제에서 useMemo를 사용했기 때문에, count가 변경되지 않으면 getDouble 함수가 다시 실행되지 않습니다.
따라서 text 변경 시에는 getDouble 함수가 다시 실행되지 않습니다.
만약 useMemo를 사용하지 않으면, text 변경 시에 getDouble 함수가 다시 실행됩니다.
1234export function getDouble(count: number) { console.log('getDouble 함수 실행!') 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) // ❌ 컴포넌트가 리렌더링될 때마다 함수가 다시 실행됨 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)}>증가</button> </> ) }
만약 React Compiler를 사용하는 경우, useMemo를 사용하지 않아도 됩니다.
다음 예제에서 text 변경되어 컴포넌트 함수가 다시 실행되더라도 count 값이 변경되지 않으면 getDouble 함수는 다시 실행되지 않습니다.
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)}>증가</button> </> ) }
# useCallback
특정 함수를 메모이제이션(Memoization)하여 의존성 배열의 값이 변경될 때만 함수를 다시 생성합니다.
불필요한 리렌더링을 방지하고 성능을 최적화하는 데 사용됩니다.
다음 함수들은 각각 다른 대상을 메모이제이션합니다.
memo: 컴포넌트useMemo: 값useCallback: 함수
다음 예제에서 text 상태의 변경은 Button 컴포넌트와 관련이 없지만, 부모 컴포넌트가 리렌더링되면 handleClick 함수도 다시 생성되며 Button 컴포넌트가 불필요하게 같이 리렌더링됩니다.
따라서 useCallback를 사용해 count 상태 변경 시에만 함수를 다시 생성하도록 하고, 추가로 Button 컴포넌트에서 memo 함수를 사용해서 Props가 변경되지 않으면 리렌더링하지 않도록 합니다.
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 컴포넌트 렌더링!') 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) // ❌ 컴포넌트가 리렌더링될 때마다 함수가 다시 생성됨 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}>클릭!</Button> </> ) }
만약 React Compiler를 사용하는 경우, useCallback이나 memo 함수를 사용하지 않아도 됩니다.
다음 예제에서 text 변경되어 컴포넌트 함수가 다시 실행되더라도 handleClick 함수는 다시 생성되지 않습니다.
그리고 Button 컴포넌트에서도 memo 함수를 사용하지 않아도 불필요한 리렌더링을 방지할 수 있습니다.
12345678910import type { ButtonHTMLAttributes } from 'react' interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: React.ReactNode } export default function Button({ children, ...restProps }: ButtonProps) { console.log('Button 컴포넌트 렌더링!') 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}>클릭!</Button> </> ) }
# useContext
createContet를 통해 생성된 Context 객체를 통해, 상위 컴포넌트에서 <Context.Provider> 컴포넌트의 value Prop으로 데이터를 제공하면, 하위 컴포넌트에서 useContext 훅으로 그 데이터를 사용할 수 있습니다.
더 자세한 내용은 React Context API 핵심 정리를 참고하세요.
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> }
# 양식
# useId
고유 ID를 생성해 HTML id 속성(Attribute)의 값으로 사용하고 다른 요소에서 쉽게 참조할 수 있습니다.
양식(Form)이나 접근성(Accessibility) 속성에서 사용하며, 리스트 랜더링의 key 속성 값으로는 사용할 수 없습니다.
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
<form> 컴포넌트의 액션(action)으로 사용할 데이터와 함수를 정의하고, 그 데이터, 함수 그리고 로딩 상태를 반환합니다.
12const 액션 = async (초깃값, 제출데이터) => 결괏값 const [결괏값, 액션, 로딩] = useActionState(액션, 초깃값)
1234567891011121314151617181920212223242526272829import { useActionState } from 'react' import type { User } from '@/api/user' export default function App() { const [user, formAction, isPending] = useActionState( // 액션 async (_initialState: User, formData: FormData) => { await new Promise(resolve => setTimeout(resolve, 1000)) // 강제 지연 const name = formData.get('name') as string return { id: 1, name } }, // 초깃값 {} as User ) return ( <> <form action={formAction}> <input name="name" placeholder="이름" /> <button type="submit">제출</button> </form> {isPending && <div>로딩 중...</div>} {user?.name && <div>이름: {user.name}</div>} </> ) }
# useFormStatus
부모 <form> 컴포넌트의 제출 정보를 반환해 하위 컴포넌트에서 쉽게 사용할 수 있습니다.
이를 통해 관심사를 분리하고 컴포넌트의 재사용성을 높일 수 있습니다.
react가 아닌 react-dom에서 제공하는 훅으로, 다음과 같은 상태 정보를 반환합니다.
pending: 제출 중인지 여부data: 제출 결과method: 제출 메소드(GET, POST, DELETE 등)action: 제출 액션(함수)
12345678910111213import { useFormStatus } from 'react-dom' export default function SubmitButton() { const { pending, data, method, action } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? '제출 중...' : '제출'} </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> </> ) }
# 전환
# useTransition
리액트는 18버전부터 동시성 모드(Concurrency Mode)를 통해 자체적인 스케줄러로 작업의 우선순위를 정할 수 있습니다.useTransition 훅을 사용해 무겁거나 사용자 경험에 중요하지 않은 작업(액션)을 낮은 우선순위로 전환(Transition)해 프레임률을 유지하고 다른 중요한 업데이트를 우선 처리(Non-Blocking Updates)합니다.
다음 예제에서 버튼을 연속으로 클릭하면, 업데이트가 누적되며 로딩 후 카운트가 클릭 횟수만큼 연속적으로 증가합니다.
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>카운트: {isPending ? <span>처리 중..</span> : count}</h2> <button onClick={handleClick}>클릭!</button> </> ) }
이번에는 다음 예제와 같이 useTransition 훅을 사용하도록 수정합니다.startTransition으로 감싼 함수(액션)의 업데이트는 다른 중요한 업데이트에 의해 방해받을 수 있으며, isPending 상태를 통해 로딩 중임을 표시할 수 있습니다.
수정 후 버튼을 연속으로 클릭하면, 업데이트가 지연되며 로딩 후 마지막 업데이트를 우선해 최종 카운트만 표시됩니다.
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
useTransition 훅과 마찬가지로, 중요하지 않은 값(Value)을 낮은 우선순위 업데이트로 지연합니다.
높은 우선순위 작업을 실행하는 동안, 이전 값을 유지하면서 업데이트를 지연해 UI 블로킹을 방지(Non-Blocking Updates)합니다.
각 훅은 다음 대상을 낮은 우선순위 업데이트로 지연합니다.
useTransition: 함수useDeferredValue: 값
다음 예제에서 SlowList 컴포넌트 출력은 인위적으로 느려지도록 하고, useDeferredValue를 통해 입력 반응성을 유지합니다.
따라서 화면에서 인풋에 텍스트를 입력할 때, UI의 블로킹이 없으므로 부드럽게 입력할 수 있습니다.
만약 useDeferredValue를 사용하지 않으면, 인풋에 텍스트를 입력할 때 UI의 블로킹이 발생하게 됩니다.
12345678910111213141516171819202122232425262728293031323334import { memo, useState, useDeferredValue } from 'react' function SlowItem({ text }: { text: string }) { const startTime = performance.now() while ((performance.now() - startTime) < 1) { // 항목당 1ms 동안 아무것도 하지 않음으로써 매우 느린 코드를 실행합니다. } 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
React 기반이 아닌 외부 데이터를 선택해 변화를 구독할 수 있습니다.
useSyncExternalStore 훅을 호출할 때 구독 함수(subscribe)와 선택 함수(getSnapshot)를 인수로 제공하면, 구독하는 데이터(스냅샷)를 얻을 수 있습니다.
구독 함수는 구독을 위해 호출할 콜백을 인수로 받아 처리하고, 컴포넌트가 해제될 때 구독을 취소하기 위한 클린업 함수도 반환해야 합니다.
선택 함수는 구독할 외부 데이터를 반환하며, 내부적으로 Object.is 메소드로 변경을 스냅샷으로 비교해 컴포넌트를 리렌더링합니다.
따라서 구독할 외부 데이터는 불변해야 합니다.
123456const 구독함수 = (콜백) => { // 구독 처리 with 콜백.. return 클린업함수 } const 선택함수 = () => 구독할데이터 const 구독한데이터 = useSyncExternalStore(구독함수, 선택함수)
다음 예제에서는 화면의 크기가 변경될 때마다 화면의 가로 너비(window.innerWidth)를 출력합니다.useEffect 사용의 경우, 컴포넌트 렌더링 후 가로 너비를 확인하므로 화면에서 일시적으로 sizeA 상태의 초깃값인 0이 표시됩니다.
하지만 useSyncExternalStore를 사용하면, 렌더링 시작 시점에 값을 구독해 초깃값을 확보(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() // 초깃값 확보 window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) } }, []) // useSyncExternalStore const sizeB = useSyncExternalStore(subscribe, getSnapshot) return ( <> <h2>useEffect: {sizeA}</h2> <h2>useSyncExternalStore: {sizeB}</h2> </> ) }
반응성(Reactivity)이 없는 외부 스토어(Redux, Zustand 등)와 함께 사용할 수도 있으며, 불필요한 리렌더링을 방지합니다.
1234567891011import { create } from 'zustand' import { combine } from 'zustand/middleware' // for 타입 추론 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
낙관적 업데이트(Optimistic Update)을 보다 쉽게 구현할 수 있습니다.
낙관적 업데이트는 비동기 작업(서버 요청 등)의 결과를 기다리지 않고, 먼저 UI를 업데이트하는 기능을 말합니다.
서버 응답이 느린 상황에서도 빠른 인터페이스를 제공할 수 있어 사용자 경험을 크게 향상시킬 수 있는 방법입니다.<form> 컴포넌트의 액션 혹은 startTransition에서 사용해 업데이트가 블로킹되지 않도록 처리해야 합니다.
123const 업데이트함수 = (이전or초깃값, 새로운값) => 낙관값 const [낙관값, 업데이트함수] = useOptimistic(초깃값, 업데이트함수) 낙관적업데이트함수(새로운값)
123456789101112131415161718192021222324252627282930313233343536373839import { useRef, useOptimistic, startTransition } from 'react' export default function App() { // 복구용 상태 정의 const backupCountRef = useRef(0) // 낙관적 상태 정의 const [count, setCount] = useOptimistic( backupCountRef.current, (_oldCount: number, newCount: number) => newCount ) // 성공하거나 실패하는 함수에서 사용 async function increase(isSuccess: boolean) { startTransition(async () => { // 1. 낙관적으로 사용할 값 정의 const newCount = count + 1 // 2. 낙관적으로 즉시 UI 업데이트 setCount(newCount) // 3. 성공하거나 실패하는 작업 수행 await new Promise(resolve => setTimeout(resolve, 2000)) if (isSuccess) { // 4-1. 성공하면 복구용 상태도 업데이트 backupCountRef.current = newCount console.log('성공!') } else { // 4-2. 실패하면 업데이트 값을 원래대로 되돌리기 setCount(backupCountRef.current) console.log('실패!') } }) } return ( <> <h2>값: {count}</h2> <button onClick={() => increase(true)}>+1 (성공)</button> <button onClick={() => increase(false)}>+1 (실패)</button> </> ) }
# 디버깅
# useDebugValue
React Developer Tools에 표시될 사용자 정의 훅(Custom Hook)의 정보를 추가하고 개발 중 디버깅을 목적으로 사용합니다.useDebugValue 훅의 첫 번째 인수로 표시할 값을 전달하며, 필요한 경우 두 번째 인수로 포매팅 함수를 전달할 수 있습니다.
포매팅 함수는 표시될 값을 새로운 형태로 반환하며, React Developer Tools가 실제로 열려있을 때만 사용됩니다.
1useDebugValue(표시될값, 포매팅함수?)
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 } }
zoom_out_map
# Custom Hook
커스컴 훅(Custom Hook)은 리액트 제공 훅(useState, useEffect 등)을 조합해 만드는 함수로, 컴포넌트 간에 상태 관련 로직을 재사용할 수 있도록 해주는 강력한 방법입니다.
이를 통해 코드 중복을 줄이고, 관심사(Concern)를 분리하여 컴포넌트를 더 깔끔하고 유지보수하기 쉽게 만들 수 있습니다.
123456789101112131415import { useState, useCallback } from 'react' export function useInput(initialText = '') { const [value, setValue] = useState(initialText) // 입력 변경 핸들러 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} /> </> ) }
# 작성 가이드
커스텀 훅은 반드시 다음 내용에 맞게 작성해야 합니다.
- 훅의 이름이 항상
useXXX이 되도록,use접두사를 사용합니다. - 일반 훅과 마찬가지로, 커스텀 훅 내부에서 사용하는 다른 훅은 조건문, 반복문, 중첩 함수 등의 하위 블록이 아닌 최상위 블록에서만 호출돼야 합니다.
- 단일 책임 원칙(SRP, Single Responsibility Principle) 준수해, 하나의 커스텀 훅은 하나의 명확한 로직(예: 데이터 패칭, 윈도우 크기 추적, 양식의 상태 관리)을 담당하도록 만듭니다.
기능이 너무 비대해지면 여러 훅으로 분리하는 것이 좋습니다.
# 최적화 전략
커스텀 훅은 컴포넌트 내부에 상태 로직을 캡슐화하기 때문에, 컴포넌트의 불필요한 리렌더링을 방지하는 것이 주요 최적화 목표입니다.
# 메모이제이션 활용
커스텀 훅 내부에서 생성되는 값이나 함수가 컴포넌트의 리렌더링을 불필요하게 유발하지 않도록 메모이제이션을 사용합니다.
React Compiler를 사용한다면, 대부분의 경우 메모이제이션 함수를 사용하지 않아도 됩니다.
- 커스텀 훅이 함수를 반환하는 경우,
useCallback훅으로 감싸는 것이 좋습니다.
컴포넌트가 리렌더링될 때마다 새로운 함수 인스턴스가 생성되는 것을 방지하며, 특히 반환 함수가 하위 컴포넌트의 Props으로 전달될 경우,memo함수를 사용한 하위 컴포넌트의 불필요한 리렌더링을 막아줍니다. - 계산 비용이 높은 값을 커스텀 훅 내부에서 사용할 때는
useMemo를 사용하는 것이 좋습니다.
의존성 배열이 변경되지 않는 한, 이전에 계산된 값을 재사용하여 불필요한 연산을 막을 수 있습니다.
# 사이드 이펙트 관리
커스텀 훅 내에서 useEffect를 사용할 때 의존성 배열을 명확하게 관리해 불필요한 재실행을 방지합니다.
- 항상 최신의 상태를 사용하도록 필요한 의존성을 포함하되, 변경될 필요가 없는 상태는 의존성에서 제외합니다.
- 린트 규칙(경고, 에러 등)에 의해 불필요하게 모든 의존성을 명시해야 할 수 있으니 주의합니다.
- 커스텀 훅이 사용되는 컴포넌트가 언마운트되었을 때, 메모리 누수가 발생하지 않도록 클린업 함수에서 등록된 이벤트를 제거합니다.
# 참조 사용
반응성을 가지는 상태로 관리할 필요가 없는, 단순 값을 참조하거나 DOM 요소에 접근할 때는 useRef를 사용합니다.useRef에 값을 저장하고 변경하는 것은 리렌더링을 유발하지 않기 때문에, 잦은 변경이 발생하는 값을 관리할 때 유용합니다.
1234567891011121314const timerRef = useRef<number>(null) useEffect(() => { timerRef.current = setInterval(() => { console.log('타이머 실행 중..') }, 1000) return () => { if (timerRef.current !== null) { clearInterval(timerRef.current) console.log('타이머 클리어!') } } }, [])
# HTTP 요청 캡슐화 예제
HTTP 통신 로직은 비동기 상태, 로딩 상태, 에러 처리 등 복잡한 상태 관리를 요구합니다.
이를 커스텀 훅으로 분리하면 컴포넌트의 부담을 줄이고 로직의 재사용성을 극대화할 수 있습니다.
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 // 이전 요청이 있는 경우 취소 if (abortControllerRef.current) { abortControllerRef.current.abort() } // 요청 취소 처리를 위한 AbortController 인스턴스 생성 abortControllerRef.current = new AbortController() const signal = abortControllerRef.current.signal const fetchData = async () => { // 로딩 및 에러 초기화 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() // 클린업 함수 return () => { if (abortControllerRef.current) { abortControllerRef.current.abort() } } // 가변성으로 인한 무한 루프가 방지하도록 JSON.stringify 사용! // 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">검색</button> </div> </form> {loading && <div>검색 중...</div>} {error && <div>에러: {error.message}</div>} {data && data.Response === 'False' && <div>검색 결과가 없습니다.</div>} {data && data.Search && ( <ul> {data.Search.map(movie => ( <li key={movie.imdbID}>{movie.Title}</li> ))} </ul> )} </> ) }
# 캐싱 등 고도화
더 고도화된 패칭 전략, 데이터 캐싱 등을 위해 SWR, TanStack Query 같은 라이브러리를 사용할 수 있습니다.
커스텀 훅을 기반으로 캐싱, 재검증(Revalidation), 포커스 시 재요청 등의 고급 기능을 제공합니다.
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) } }
끝까지 읽어주셔서 감사합니다.
좋아요와 응원 댓글은 블로그 운영에 큰 힘이 됩니다!