📌 커스텀 훅(Custom Hook)
: 여러 컴포넌트에서 재사용할 수 있는 로직을 추출해 하나의 함수로 정의한 것
- 코드의 재사용성을 높이고, 코드의 가독성을 개선할 수 있다.
☑️ 빌트인 훅 함수
: 리액트는 useState(), useEffect(), useRef() 등 빌트인 훅 함수를 제공한다.
- 빌트인 훅 함수만 가지고는 모든 것을 처리할 수 없기 때문에 커스텀 훅을 만들어 사용한다.
- useState()
- useRef()
- useEffect()
- useDebugValue()
- useMemo()
- useSyncExternalStore()
- useCallback()
- useLayoutEffect()
- useImperativeHandle()
- useId()
⏩ 커스텀 훅(Custom Hook)이 필요한 경우
- 반복되는 로직이 있을 때
➡︎ 컴포넌트 간 로직 공유 - 상태와 사이드 이펙트를 분리하고 싶을 때
- 가독성과 유지보수성을 높이고 싶을 때
⏩ 커스텀 훅(Custom Hook) 최적화하기
- 커스텀 훅 함수를 작성할 경우, 리렌더링 이슈 방지와 효과적인 렌더링 관리를 위해 반환하는 모든 함수를 useCallback() 훅으로 감싸는 것이 좋다.
✅ 커스텀 훅(Custom Hook) 사용 규칙
1️⃣ Hook의 이름은 항상 use로 시작한다.
- React 컴포넌트의 이름은 항상 대문자로 시작해한다.
- JSX처럼 어떻게 보이는지 React가 알 수 있는 무언가를 반환해야 한다.
(ex. StatusBar, SaveButton) - Hook의 이름은 use 뒤에 대문자로 시작한다.
(ex. useState (내장된 Hook), useOnlineStatus (커스텀 Hook))
- ex) getColor()라는 함수 ➡︎ 함수명이 use로 시작하지 않으므로 함수 안에 상태(state)가 없다는 것을 확신할 수 있다.
useOnlineStatus() 함수 ➡︎ 내부에 다른 Hook을 사용하고 있을 확률이 높다.
- 커스텀 Hook은 같은 Hook을 호출하더라도 각각의 Hook 호출은 완전히 독립되어 있어야 한다.
- 커스텀 Hook을 추출하기 전과 후가 동일해야(순수해야)한다!
➡︎ 리액트 컴포넌트가 재렌더링될 때마다 그 컴포넌트 내에서 호출된 모든 훅은 다시 실행되기 때문에
훅은 동일한 입력이 주어졌을 때 항상 동일한 출력을 반환해야 한다. - 커스텀 Hook은 컴포넌트에서 반복되는 로직을 추출하여 만든다.
- state가 존재한다.
(ex. firstName와 lastName) - 변화를 다루는 함수가 존재한다.
(ex. handleFirstNameChange와 handleLastNameChange) - 해당 입력에 대한 로직의 속성을 지정하는 JSX가 존재한다.
(ex. value와 onChange)
- state가 존재한다.
3️⃣ Hook 사이에 상호작용하는 값 전달하기
- 리액트의 훅 간에 상태나 값을 공유하고, 항상 최신의 props와 state를 전달받아야 올바르게 동작한다.
(ex. useState로 관리하는 상태를 useEffect에서 사용하는 경우) - 훅(Hook)은 컴포넌트가 재렌더링될 때마다 다시 호출되므로,
항상 최신의 props와 state를 기반으로 동작해야 한다.
(ex. 서버에서 데이터를 가져오는 useFetch(커스텀 훅)
이 훅은 컴포넌트에서 전달받은 URL을 이용해 데이터를 가져온다.
이때 컴포넌트가 재렌더링되어 URL이 변경되면, useFetch 훅은 새로운 URL을 사용하여 데이터를 다시 가져온다.)
import React, { useState, useEffect } from 'react';
function MessageComponent() {
// 상태를 관리하는 훅
const [count, setCount] = useState(0);
// 상태가 변경될 때마다 콘솔에 메시지를 출력하는 훅
useEffect(() => {
console.log(`You have clicked the button ${count} times`);
}, [count]); // 이펙트는 최신 count 값을 의존성으로 받음
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
export default MessageComponent;
☑️ 문서 제목 변경
- useDocumentTitle() 훅
import { useLayoutEffect } from 'react';
/** @type { (documentTitle: string) => void } */
function useDocumentTitle(documentTitle) {
useLayoutEffect(() => {
document.title =
documentTitle + ' - ' + import.meta.env.VITE_DOCUMENT_TITLE;
}, [documentTitle]);
}
export default useDocumentTitle;
// 사용 ---------------------
const documentTitle = '시계 ON/OFF ← 이펙트 동기화 & 정리';
useDocumentTitle(documentTitle);
☑️ 이벤트 연결 (자동 해지)
- useEventListener() 훅
import { useEffect } from 'react';
/** @type { (target: Document | HTMLElement, eventType: string, eventHandler: (e: Event) => void) => void} */
function useEventListener(target, eventType, eventHandler) {
useEffect(() => {
target.addEventListener(eventType, eventHandler);
return () => {
target.removeEventListener(eventType, eventHandler);
};
}, [target, eventType, eventHandler]);
}
export default useEventListener;
// 사용 --------------------
const onMouseTracking = useCallback(({ pageX: x, pageY: y }) => {
setMousePosition({ x, y });
}, []);
// 종속성 배열이 비어 있기 때문에 이 함수 참조는 리-렌더링 과정에서 항상 동일하다.
useEventListener(document, 'mousemove', onMouseTracking);
☑️ 마우스 위치 추적
- useMousePosition() 훅
import { useCallback, useState } from 'react';
import useEventListener from './useEventListener';
function useMousePosition() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const onMouseTracking = useCallback(({ pageX: x, pageY: y }) => {
setMousePosition({ x, y });
}, []);
useEventListener(document, 'mousemove', onMouseTracking);
return mousePosition; // { x, y }
}
export default useMousePosition;
// 사용 ---------------
import useDocumentTitle from '@/hooks/useDocumentTitle';
import S from './PrintMousePosition.module.css';
import useMousePosition from '@/hooks/useMousePosition';
function PrintMousePosition() {
useDocumentTitle('마우스 위치 추적 ← 이펙트 동기화 & 정리');
const { x, y } = useMousePosition();
return (
<div className={S.component}>
<output>
{x} <span>/</span> {y}
</output>
</div>
);
}
export default PrintMousePosition;
☑️ 온/오프라인 상태
- useOnline() 훅
- useState(), useCallback() 훅으로 구현
- useSyncExternalStore() 훅으로 구현
import {
useCallback,
useDebugValue,
useState,
useSyncExternalStore,
} from 'react';
import useEventListener from './useEventListener';
function useOnline() {
// isOnline 상태 선언
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
// 리-렌더링 되더라도 처음에 기억한 함수를 동일 참조
// 불필요한 리-렌더를 차단 (성능 최적화)
const onOnline = useCallback(() => setIsOnline(true), []);
const onOffline = useCallback(() => setIsOnline(false), []);
// 커스텀 훅
// 자동 이벤트 연결 및 해지
useEventListener(globalThis, 'online', onOnline);
useEventListener(globalThis, 'offline', onOffline);
// 개발도구에서 표시되는 레이블 지정
useDebugValue(isOnline, () => (isOnline ? '온라인' : '오프라인'));
return isOnline;
}
// 사용 ----------------
import useOnline from '@/hooks/useOnline';
import Switcher from '../sync-web-storage/components/Switcher';
function CheckOnOffline() {
const isOnline = useOnline();
return (
<div style={{ display: 'flex', flexFlow: 'column', gap: 20 }}>
<h1>Check On/Offline</h1>
<Switcher value={isOnline} />
</div>
);
}
export default CheckOnOffline;
☑️ 시계 정보 표시
- useClock() 훅
import { useDebugValue, useEffect, useState } from 'react';
function useClock() {
// 타임 상태 선언
const [time, setTime] = useState(new Date());
// 타임 업데이트 이펙트
useEffect(() => {
const clearId = setInterval(() => {
const nextTime = new Date();
setTime(nextTime);
}, 1000);
return () => {
clearInterval(clearId);
};
}, []);
// 리액트 개발 도구 포멧 표시 설정
useDebugValue(time, () => time.toLocaleTimeString());
// 상태 값 반환
return time;
}
export default useClock;
// 사용 ---------------
import { bool, func } from 'prop-types';
import S from './ClockOnOff.module.css';
import useDocumentTitle from '@/hooks/useDocumentTitle';
import useClock from '@/hooks/useClock';
ClockOnOff.propTypes = {
isOn: bool,
onToggle: func,
};
function ClockOnOff({ isOn = false, onToggle }) {
useDocumentTitle('시계 ON/OFF ← 이펙트 동기화 & 정리');
const time = useClock();
const buttonLabel = isOn ? 'OFF' : 'ON';
return (
<div className={S.component}>
<button type="button" lang="en" onClick={onToggle}>
CLOCK {buttonLabel}
</button>
<output hidden={!isOn}>{time.toLocaleTimeString()}</output>
</div>
);
}
export default ClockOnOff;
☑️ 상태 변경 & 콜백
- useStateWithCallbak() 훅
import { useEffect, useState } from 'react';
/** @type {(initialValue: any, callback?: (nextState: any) => void) => [state, setState]} */
function useStateWithCallback(initialValue, callback) {
// 상태 선언
const [state, setState] = useState(initialValue);
// 상태를 추적하는 이펙트 추가
useEffect(() => {
callback?.(state);
// callback이 변경된다고 해서 이펙트 함수가 다시 실행되어서는 안된다.
}, [state]);
// 반환 값
return [state, setState];
}
export default useStateWithCallback;
// 사용 -----------------
function ChangeStateAndCallback() {
const [count, setCount] = useStateWithCallback(0);
const [message, setMessage] = useStateWithCallback(
/* 초기 상태 */
'hello',
/* 상태 업데이트 이후 실행이 보장되는 콜백 함수 (이펙트 코드 추가 가능) */
(nextMessage) => {
pRef.current.textContent = nextMessage.toUpperCase();
setCount((c) => c + 1);
}
);
const handleChangeMessage = () => {
setMessage((m) => `${m} ❤️`);
};
const pRef = useRef(null);
return (
<>
<button type="button" style={buttonStyles} onClick={handleChangeMessage}>
메시지 변경 ({count})
</button>
{/* 선언적 프로그래밍 */}
<output style={outputStyles}>{message.toUpperCase()}</output>
<p ref={pRef} style={pStyles} />
</>
);
}
const buttonStyles = { alignSelf: 'start' };
const outputStyles = { fontWeight: 800 };
const pStyles = {
border: '3px solid #d9dedf',
borderRadius: 4,
margin: 0,
padding: 6,
};
☑️ 상태 토글
- useToggle() 훅
import useStateWithCallback from './useStateWithCallback';
// 1. 직접 구현
/** @type {(initialValue?: boolean, callback?: (nextState) => void) => [isToggle, setIsToggle]} */
function _useToggle(initialValue = false, callback) {
const [isToggle, setIsToggle] = useState(initialValue);
useEffect(() => {
callback?.(isToggle);
}, [isToggle]);
return [isToggle, setIsToggle];
}
// 2. useStateWithCallback() 커스텀 훅 활용
/** @type {(initialValue?: boolean, callback?: (nextState) => void) => [isToggle, setIsToggle]} */
function useToggle(initialValue = false, callback) {
return useStateWithCallback(initialValue, callback);
}
export default useToggle;
// 사용 ---------------------
function CheckOnOffline() {
// const isOnline = useOnline();
const [isToggle, setIsToggle] = useToggle(false, (nextIsToggle) => {
document.body.style.backgroundColor = nextIsToggle ? '#2f2f2f' : 'white';
});
return (
<div style={{ display: 'flex', flexFlow: 'column', gap: 20 }}>
<h1>Check On/Offline</h1>
<Switcher value={isToggle} onToggle={() => setIsToggle(t => !t)} />
<p>{ isToggle.toString() }</p>
<ChangeStateAndCallback />
</div>
);
}
export default CheckOnOffline;
☑️ 뷰포트 진입/진출
- useInView() 훅
import { useLayoutEffect, useRef, useState } from 'react';
/** @type{ (printLog?: boolean) => { inView, targetRef, rootRef } } */
function useInView(printLog = false) {
const [inView, setInView] = useState(false);
// inView 상태 -> 뷰포트 안에 관찰 대상이 들어왔는 지(참) / 들어오지 않았는 지(거짓) 반환
const targetRef = useRef(null);
// targetRef 참조 -> 문서에 존재하고 뷰포트 내부에 진입/진출 여부를 관찰할 문서 요소 참조
const rootRef = useRef(document);
// rootRef 참조 -> 문서에 존재하고 뷰포트로서 설정할 루트 요소 참조
useLayoutEffect(() => {
printLog && console.log('인터섹션 옵저버 생성, 관찰 대상 관찰하도록 설정');
const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
printLog && console.log('뷰포트(rootRef.current) 안에 진입');
setInView(true);
} else {
printLog && console.log('뷰포트(rootRef.current) 밖으로 진출');
setInView(false);
}
});
const { current: targetElement } = targetRef;
// targetRef.current 존재할 경우
if (targetElement) {
// 대상 요소 관찰 설정
observer.observe(targetElement);
} else {
console.warn('문서에 관찰할 대상 요소가 존재하지 않습니다.');
}
return () => {
printLog &&
console.log('인터섹션 옵저버에 의해 관찰 중인 대상의 관찰을 중지 설정');
targetElement && observer.unobserve(targetElement);
};
}, [printLog]);
return { inView, targetRef, rootRef };
}
export default useInView;
☑️ 웹 스토리지 읽기/쓰기
- useSessionStorage() 훅
- useLocalStorage() 훅
import useStateWithCallback from './useStateWithCallback';
const { localStorage, sessionStorage } = globalThis;
// 웹 스토리지 데이터 읽기
const getStorageItem = (key, storageType = 'local') => {
const storage = storageType.includes('local') ? localStorage : sessionStorage;
const item = storage.getItem(key);
const data = JSON.parse(item);
return data ?? null;
};
// 웹 스토리지 데이터 쓰기
const setStorageItem = (key, value, storageType = 'local') => {
const storage = storageType.includes('local') ? localStorage : sessionStorage;
const stringifyValue = JSON.stringify(value);
storage.setItem(key, stringifyValue);
};
// 웹 스토리지 데이터 삭제
const deleteStorageItem = (key, storageType = 'local') => {
const storage = storageType.includes('local') ? localStorage : sessionStorage;
if (!key) console.warn('삭제할 아이템의 키가 존재하지 않습니다.');
else storage.removeItem(key);
};
// 웹 스토리지 데이터 모두 삭제
const allClearItems = (storageType = 'local') => {
const storage = storageType.includes('local') ? localStorage : sessionStorage;
storage.clear();
};
// 로컬 스토리지
export function useLocalStorage(key, initialValue, autoSave = false) {
const [state, setState] = useStateWithCallback(
() => getStorageItem(key) ?? initialValue,
(nextState) => autoSave && setItem(nextState)
);
const getItem = () => getStorageItem(key);
const setItem = (newValue) => setStorageItem(key, newValue);
const deleteItem = () => deleteStorageItem(key);
const allClear = () => allClearItems();
return [
state,
setState,
/* methods */ {
getItem,
setItem,
deleteItem,
allClear,
},
];
}
// 세션 스토리지
export function useSessionStorage(key, initialValue, autoSave = false) {
const [state, setState] = useStateWithCallback(
() => getStorageItem(key) ?? initialValue,
(nextState) => autoSave && setItem(nextState)
);
const getItem = () => getStorageItem(key, 'session');
const setItem = (newValue) => setStorageItem(key, newValue, 'session');
const deleteItem = () => deleteStorageItem(key, 'session');
const allClear = () => allClearItems('session');
return [
state,
setState,
{
getItem,
setItem,
deleteItem,
allClear,
},
];
}
export default {
useLocalStorage,
useSessionStorage,
};
☑️ 네트워크 요청/응답
- useFetch() 훅
// [목적]
// 리액트 렌더링 프로세스(동기)와 관련 없는 사이드 이펙트(비동기) 처리
// 네트워크 요청/응답 처리하기 위한 상태
// - 상황(status) 상태
// - 오류(error) 유무 상태
// - 데이터(data) 유무 상태
// [기능]
// - 비동기 요청/응답
// - 응답 상황에 따른 상태 반환
// - 개발 중(StrictMode) 2회 렌더링 되는 문제(순수성 검사) 해결 (중복 요청 취소)
// [사용법]
// const { status, error, data } = useFetch(url);
// if (status === 'pending' | 'loading' | 'success' | 'error') { ... }
// if (error || status === 'error') { ... }
// if (status === 'success' || data) { ... }
// ============================================
// import { useState } from "react";
import { useEffect } from 'react';
import { useImmer } from 'use-immer';
/** @type {(url: string) => { status: 'pending' | 'loading' | 'success' | 'error', error: null | Error, data }} */
function useFetch(url) {
// 1. 상태 선언
const [state, setState] = useImmer({
status: 'pending',
error: null,
data: null,
});
// 2. 이펙트 처리
useEffect(() => {
// 요청을 취소할 수 있고, 신호를 제공할 수 있는 객체 생성
const abortController = new AbortController();
// 상태 업데이트 (대기 -> 로딩 중...)
setState((draft) => {
draft.status = 'loading';
});
// 이펙트 내부의 비동기 함수
const fetchData = async () => {
try {
// 요청 / 응답
const response = await fetch(url, {
signal: abortController.signal,
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
// 응답이 실패한 경우
// catch(error) 블록으로 응답
if (!response.ok) {
throw new Response(
{ message: '서버 응답이 실패했습니다.' },
{ status: 500 }
);
}
// 응답 결과에서 JSON 데이터로 변환
const responseData = await response.json();
// 응답이 성공한 경우
// 상태 업데이트 (로딩 중... -> 성공)
setState((draft) => {
draft.status = 'success';
draft.data = responseData;
});
} catch (error) {
if (!(error instanceof DOMException)) {
// 응답이 실패한 경우
// 상태 업데이트 (로딩 중... -> 실패)
setState((draft) => {
draft.status = 'error';
draft.error = error;
});
}
}
};
// 이펙트 함수 실행
fetchData();
// 정리
return () => {
// 중복 요청인 경우, 요청 취소
abortController.abort();
};
}, [url, setState]);
// 3. 반환 값
// { status, error, data }
return state;
}
export default useFetch;
커스텀 Hook으로 로직 재사용하기 – React
The library for web and native user interfaces
ko.react.dev
useCallback – React
The library for web and native user interfaces
ko.react.dev