React에는 useState, useContext, useEffect 같은 내장 Hook들이 여러 개 있어요. 가끔은 좀 더 구체적인 목적을 위한 Hook이 있었으면 좋겠다고 생각할 때가 있을 거예요. 예를 들어, 데이터를 가져오거나, 사용자가 온라인 상태인지 추적하거나, 채팅방에 연결하는 것 같은 기능 말이죠. React에서 이런 Hook들을 찾지 못할 수도 있지만, 여러분의 애플리케이션 요구사항에 맞는 Hook을 직접 만들 수 있어요.
네트워크에 크게 의존하는 앱을 개발하고 있다고 상상해보세요 (대부분의 앱이 그렇죠). 사용자가 앱을 사용하는 중에 실수로 네트워크 연결이 끊어졌을 때 경고를 주고 싶어요. 어떻게 할 건가요? 컴포넌트에 두 가지가 필요할 것 같네요:
이렇게 하면 컴포넌트를 네트워크 상태와 동기화할 수 있어요. 이런 식으로 시작할 수 있겠죠:
import { useState, useEffect } from 'react';
export default function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
네트워크를 켜고 끄면서, 이 StatusBar가 여러분의 동작에 반응해서 어떻게 업데이트되는지 확인해보세요.
이제 또 다른 컴포넌트에서도 같은 로직을 사용하고 싶다고 생각해보세요. 네트워크가 꺼져있을 때 비활성화되고 "Save" 대신 "Reconnecting..." 을 보여주는 저장 버튼을 구현하고 싶어요.
시작하려면, isOnline state와 Effect를 SaveButton에 복사해서 붙여넣을 수 있어요:
import { useState, useEffect } from 'react';
export default function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
네트워크를 끄면 버튼의 모양이 바뀌는지 확인해보세요.
두 컴포넌트 모두 잘 작동하지만, 둘 사이의 로직 중복은 아쉬운 부분이에요. 시각적 외관은 다르지만, 로직은 재사용하고 싶은 것 같죠.
잠깐 상상해보세요. useState와 useEffect처럼, 내장된 useOnlineStatus Hook이 있다면요. 그러면 이 두 컴포넌트를 단순화하고 중복을 제거할 수 있겠죠:
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
이런 내장 Hook은 없지만, 직접 작성할 수 있어요. useOnlineStatus라는 함수를 선언하고, 앞서 작성한 컴포넌트들의 중복 코드를 모두 그 안으로 옮기세요:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
함수의 마지막에 isOnline을 반환하세요. 그러면 컴포넌트가 그 값을 읽을 수 있어요:
// App.js
import { useOnlineStatus } from './useOnlineStatus.js';
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
export default function App() {
return (
<>
<SaveButton />
<StatusBar />
</>
);
}
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
네트워크를 켜고 끄면 두 컴포넌트가 모두 업데이트되는지 확인해보세요.
이제 컴포넌트들은 반복적인 로직이 많지 않아요. 더 중요한 건, 컴포넌트 내부의 코드가 어떻게 하는지(브라우저 이벤트를 구독해서)보다 무엇을 하고 싶은지(온라인 상태를 사용하고 싶다!)를 설명한다는 거예요.
로직을 커스텀 Hook으로 추출하면, 외부 시스템이나 브라우저 API를 어떻게 다루는지에 대한 복잡한 세부사항을 숨길 수 있어요. 컴포넌트의 코드는 구현이 아니라 의도를 표현하게 되죠.
use로 시작해요 {/hook-names-always-start-with-use/}React 애플리케이션은 컴포넌트로 만들어져요. 컴포넌트는 내장 Hook이든 커스텀 Hook이든 Hook으로 만들어지죠. 다른 사람이 만든 커스텀 Hook을 자주 사용하겠지만, 가끔은 직접 작성할 수도 있어요!
다음과 같은 네이밍 규칙을 따라야 해요:
StatusBar나 SaveButton 같이요. React 컴포넌트는 또한 JSX 조각처럼 React가 표시할 줄 아는 무언가를 반환해야 해요.use 다음에 대문자로 시작해야 해요. useState(내장) 또는 useOnlineStatus(커스텀, 페이지 앞부분에서 본 것처럼) 같이요. Hook은 임의의 값을 반환할 수 있어요.이 규칙은 컴포넌트를 봤을 때 state, Effect, 그리고 다른 React 기능이 어디에 "숨어있을지" 항상 알 수 있게 보장해줘요. 예를 들어, 컴포넌트 내부에서 getColor() 함수 호출을 본다면, 이름이 use로 시작하지 않기 때문에 내부에 React state가 없다는 걸 확신할 수 있어요. 하지만 useOnlineStatus() 같은 함수 호출은 내부에 다른 Hook 호출을 포함하고 있을 가능성이 높죠!
린터가 React용으로 설정되어 있다면, 이 네이밍 규칙을 강제할 거예요. 위의 샌드박스로 스크롤해서 useOnlineStatus를 getOnlineStatus로 이름을 바꿔보세요. 린터가 더 이상 내부에서 useState나 useEffect를 호출하는 걸 허용하지 않는 걸 확인할 수 있어요. 오직 Hook과 컴포넌트만 다른 Hook을 호출할 수 있어요!
아니요. Hook을 호출하지 않는 함수는 Hook이 될 필요가 없어요.
함수가 어떤 Hook도 호출하지 않는다면, use 접두사를 피하세요. 대신 use 접두사 없이 일반 함수로 작성하세요. 예를 들어, 아래의 useSorted는 Hook을 호출하지 않으니까 getSorted라고 부르세요:
// 🔴 피하세요: Hook을 사용하지 않는 Hook
function useSorted(items) {
return items.slice().sort();
}
// ✅ 좋아요: Hook을 사용하지 않는 일반 함수
function getSorted(items) {
return items.slice().sort();
}
이렇게 하면 조건문을 포함한 어디에서든 이 일반 함수를 호출할 수 있어요:
function List({ items, shouldSort }) {
let displayedItems = items;
if (shouldSort) {
// ✅ getSorted()는 Hook이 아니기 때문에 조건부로 호출해도 괜찮아요
displayedItems = getSorted(items);
}
// ...
}
함수 내부에서 최소한 하나의 Hook이라도 사용한다면 use 접두사를 붙여야 해요 (그래서 Hook으로 만들어야 해요):
// ✅ 좋아요: 다른 Hook을 사용하는 Hook
function useAuth() {
return useContext(Auth);
}
기술적으로는 React가 이걸 강제하지 않아요. 원칙적으로 다른 Hook을 호출하지 않는 Hook을 만들 수도 있어요. 하지만 종종 혼란스럽고 제한적이기 때문에 그 패턴은 피하는 게 좋아요. 하지만 도움이 되는 드문 경우도 있을 수 있어요. 예를 들어, 지금은 어떤 Hook도 사용하지 않지만, 미래에 Hook 호출을 추가할 계획이라면요. 그럴 때는 use 접두사로 이름을 짓는 게 말이 돼요:
// ✅ 좋아요: 나중에 다른 Hook을 사용할 가능성이 있는 Hook
function useAuth() {
// TODO: 인증이 구현되면 이 줄로 교체:
// return useContext(Auth);
return TEST_USER;
}
그러면 컴포넌트가 조건부로 호출할 수 없게 돼요. 이건 실제로 내부에 Hook 호출을 추가할 때 중요해질 거예요. 내부에서 Hook을 사용할 계획이 없다면 (지금도 나중에도), Hook으로 만들지 마세요.
앞의 예제에서 네트워크를 켜고 끌 때, 두 컴포넌트가 함께 업데이트됐어요. 하지만 단일 isOnline state 변수가 둘 사이에서 공유된다고 생각하면 틀렸어요. 이 코드를 보세요:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
중복을 추출하기 전과 똑같은 방식으로 작동해요:
function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
이건 완전히 독립적인 두 개의 state 변수와 Effect예요! 같은 외부 값(네트워크가 켜져 있는지)과 동기화했기 때문에 같은 시간에 같은 값을 가졌을 뿐이에요.
이걸 더 잘 설명하려면 다른 예제가 필요해요. 이 Form 컴포넌트를 봐보세요:
import { useState } from 'react';
export default function Form() {
const [firstName, setFirstName] = useState('Mary');
const [lastName, setLastName] = useState('Poppins');
function handleFirstNameChange(e) {
setFirstName(e.target.value);
}
function handleLastNameChange(e) {
setLastName(e.target.value);
}
return (
<>
<label>
First name:
<input value={firstName} onChange={handleFirstNameChange} />
</label>
<label>
Last name:
<input value={lastName} onChange={handleLastNameChange} />
</label>
<p><b>Good morning, {firstName} {lastName}.</b></p>
</>
);
}
label { display: block; }
input { margin-left: 10px; }
각 form 필드에 반복적인 로직이 있어요:
firstName과 lastName).handleFirstNameChange와 handleLastNameChange).value와 onChange 속성을 지정하는 JSX가 있어요.반복적인 로직을 이 useFormInput 커스텀 Hook으로 추출할 수 있어요:
// App.js
import { useFormInput } from './useFormInput.js';
export default function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
return (
<>
<label>
First name:
<input {...firstNameProps} />
</label>
<label>
Last name:
<input {...lastNameProps} />
</label>
<p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
</>
);
}
import { useState } from 'react';
export function useFormInput(initialValue) {
const [value, setValue] = useState(initialValue);
function handleChange(e) {
setValue(e.target.value);
}
const inputProps = {
value: value,
onChange: handleChange
};
return inputProps;
}
label { display: block; }
input { margin-left: 10px; }
value라는 하나의 state 변수만 선언한 걸 주목하세요.
하지만 Form 컴포넌트는 useFormInput을 두 번 호출해요:
function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...
그래서 두 개의 별도 state 변수를 선언한 것처럼 작동하는 거예요!
커스텀 Hook은 state 저장 로직을 공유하지만 state 자체를 공유하지는 않아요. Hook에 대한 각 호출은 같은 Hook에 대한 다른 모든 호출과 완전히 독립적이에요. 그래서 위의 두 샌드박스가 완전히 동일한 거예요. 원한다면, 위로 스크롤해서 비교해보세요. 커스텀 Hook을 추출하기 전과 후의 동작이 동일해요.
여러 컴포넌트 간에 state 자체를 공유해야 할 때는, state를 끌어올려서 전달하세요.
커스텀 Hook 내부의 코드는 컴포넌트가 리렌더링될 때마다 다시 실행될 거예요. 그래서 컴포넌트처럼 커스텀 Hook도 순수해야 해요. 커스텀 Hook의 코드를 컴포넌트 본문의 일부로 생각하세요!
커스텀 Hook은 컴포넌트와 함께 리렌더링되기 때문에, 항상 최신 props와 state를 받아요. 이게 무슨 의미인지 이 채팅방 예제를 보세요. 서버 URL이나 채팅방을 바꿔보세요:
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom
roomId={roomId}
/>
</>
);
}
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현은 실제로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + '');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme = 'dark') {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
{
"dependencies": {
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
serverUrl이나 roomId를 바꾸면, Effect가 변경에 "반응"해서 다시 동기화해요. Effect의 의존성을 바꿀 때마다 채팅이 다시 연결되는 걸 콘솔 메시지로 알 수 있어요.
이제 Effect의 코드를 커스텀 Hook으로 옮겨보세요:
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
이렇게 하면 ChatRoom 컴포넌트가 내부 작동 방식을 걱정하지 않고 커스텀 Hook을 호출할 수 있어요:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
훨씬 간단해 보이죠! (하지만 같은 일을 해요.)
로직이 여전히 prop과 state 변경에 반응한다는 걸 주목하세요. 서버 URL이나 선택된 방을 수정해보세요:
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom
roomId={roomId}
/>
</>
);
}
import { useState } from 'react';
import { useChatRoom } from './useChatRoom.js';
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
import { useEffect } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현은 실제로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + '');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme = 'dark') {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
{
"dependencies": {
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
한 Hook의 반환 값을 가져오는 방법을 주목하세요:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
그리고 그걸 다른 Hook의 입력으로 전달하죠:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
ChatRoom 컴포넌트가 리렌더링될 때마다, 최신 roomId와 serverUrl을 Hook에 전달해요. 그래서 리렌더링 후 값이 다를 때마다 Effect가 채팅에 다시 연결하는 거예요. (오디오나 비디오 처리 소프트웨어를 다뤄봤다면, 이렇게 Hook을 체이닝하는 게 시각적 또는 오디오 효과를 체이닝하는 걸 떠올리게 할 거예요. useState의 출력이 useChatRoom의 입력으로 "흘러들어가는" 것 같죠.)
더 많은 컴포넌트에서 useChatRoom을 사용하기 시작하면, 컴포넌트가 동작을 커스터마이징할 수 있게 하고 싶을 거예요. 예를 들어, 현재 메시지가 도착했을 때 무엇을 할지에 대한 로직이 Hook 내부에 하드코딩되어 있어요:
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
이 로직을 컴포넌트로 다시 옮기고 싶다고 해봐요:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl,
onReceiveMessage(msg) {
showNotification('New message: ' + msg);
}
});
// ...
이걸 작동시키려면, 커스텀 Hook이 onReceiveMessage를 명명된 옵션 중 하나로 받도록 변경하세요:
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onReceiveMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl, onReceiveMessage]); // ✅ 모든 의존성 선언됨
}
이렇게 하면 작동하지만, 커스텀 Hook이 이벤트 핸들러를 받을 때 개선할 수 있는 점이 하나 더 있어요.
onReceiveMessage에 의존성을 추가하는 건 이상적이지 않아요. 컴포넌트가 리렌더링될 때마다 채팅이 다시 연결될 테니까요. 이 이벤트 핸들러를 Effect Event로 감싸서 의존성에서 제거하세요:
import { useEffect, useEffectEvent } from 'react';
// ...
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 모든 의존성 선언됨
}
이제 ChatRoom 컴포넌트가 리렌더링될 때마다 채팅이 다시 연결되지 않아요. 이벤트 핸들러를 커스텀 Hook에 전달하는 완전히 작동하는 데모를 가지고 놀아보세요:
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom
roomId={roomId}
/>
</>
);
}
import { useState } from 'react';
import { useChatRoom } from './useChatRoom.js';
import { showNotification } from './notifications.js';
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl,
onReceiveMessage(msg) {
showNotification('New message: ' + msg);
}
});
return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
import { useEffect } from 'react';
import { useEffectEvent } from 'react';
import { createConnection } from './chat.js';
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현은 실제로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl + '');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme = 'dark') {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
{
"dependencies": {
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
이제 useChatRoom을 사용하기 위해 어떻게 작동하는지 알 필요가 없다는 걸 주목하세요. 다른 어떤 컴포넌트에든 추가하고, 다른 옵션을 전달해도 같은 방식으로 작동할 거예요. 이게 커스텀 Hook의 힘이에요.
중복된 작은 코드마다 커스텀 Hook을 추출할 필요는 없어요. 약간의 중복은 괜찮아요. 예를 들어, 앞서처럼 단일 useState 호출을 감싸는 useFormInput Hook을 추출하는 건 아마 불필요할 거예요.
하지만, Effect를 작성할 때마다 커스텀 Hook으로 감싸는 게 더 명확할지 고려해보세요. Effect가 자주 필요하지는 않을 테니까요, Effect를 작성하고 있다면 외부 시스템과 동기화하거나 React에 내장 API가 없는 뭔가를 하기 위해 "React 밖으로 나가야" 한다는 의미예요. 커스텀 Hook으로 감싸면 의도와 데이터 흐름을 정확하게 전달할 수 있어요.
예를 들어, 두 개의 드롭다운을 표시하는 ShippingForm 컴포넌트를 생각해보세요: 하나는 도시 목록을 보여주고, 다른 하나는 선택된 도시의 지역 목록을 보여줘요. 이런 코드로 시작할 수 있겠죠:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// 이 Effect는 국가에 대한 도시를 가져와요
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
// 이 Effect는 선택된 도시의 지역을 가져와요
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);
// ...
이 코드가 꽤 반복적이지만, 이 Effect들을 서로 분리해 두는 게 맞아요. 두 가지 다른 것을 동기화하니까 하나의 Effect로 합치면 안 돼요. 대신, 공통 로직을 자체 useData Hook으로 추출해서 위의 ShippingForm 컴포넌트를 단순화할 수 있어요:
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
if (url) {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}
}, [url]);
return data;
}
이제 ShippingForm 컴포넌트의 두 Effect를 useData 호출로 바꿀 수 있어요:
function ShippingForm({ country }) {
const cities = useData(`/api/cities?country=${country}`);
const [city, setCity] = useState(null);
const areas = useData(city ? `/api/areas?city=${city}` : null);
// ...
커스텀 Hook을 추출하면 데이터 흐름이 명확해져요. url을 넣으면 data를 받아내죠. Effect를 useData 안에 "숨김"으로써, ShippingForm 컴포넌트를 작업하는 누군가가 불필요한 의존성을 추가하는 것도 막을 수 있어요. 시간이 지나면, 앱의 대부분의 Effect가 커스텀 Hook 안에 있게 될 거예요.
커스텀 Hook의 이름을 선택하는 것부터 시작하세요. 명확한 이름을 고르기 어렵다면, Effect가 컴포넌트의 나머지 로직과 너무 결합되어 있어서 아직 추출할 준비가 안 됐을 수 있어요.
이상적으로는, 커스텀 Hook의 이름이 코드를 자주 작성하지 않는 사람도 커스텀 Hook이 무엇을 하는지, 무엇을 받고, 무엇을 반환하는지 잘 짐작할 수 있을 만큼 명확해야 해요:
useData(url)useImpressionLog(eventName, extraData)useChatRoom(options)외부 시스템과 동기화할 때, 커스텀 Hook 이름은 더 기술적이고 그 시스템에 특정한 전문 용어를 사용할 수 있어요. 그 시스템에 익숙한 사람에게 명확하다면 좋아요:
useMediaQuery(query)useSocket(url)useIntersectionObserver(ref, options)커스텀 Hook을 구체적인 고수준 사용 사례에 집중하게 하세요. useEffect API 자체의 대안이나 편의 래퍼로 작동하는 커스텀 "라이프사이클" Hook을 만들고 사용하는 건 피하세요:
useMount(fn)useEffectOnce(fn)useUpdateEffect(fn)예를 들어, 이 useMount Hook은 코드가 "마운트 시에만" 실행되도록 보장하려고 해요:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// 🔴 피하세요: 커스텀 "라이프사이클" Hook 사용
useMount(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();
post('/analytics/event', { eventName: 'visit_chat' });
});
// ...
}
// 🔴 피하세요: 커스텀 "라이프사이클" Hook 생성
function useMount(fn) {
useEffect(() => {
fn();
}, []); // 🔴 React Hook useEffect has a missing dependency: 'fn'
}
useMount 같은 커스텀 "라이프사이클" Hook은 React 패러다임에 잘 맞지 않아요. 예를 들어, 이 코드 예제에는 실수가 있어요 (roomId나 serverUrl 변경에 "반응"하지 않아요). 하지만 린터는 직접 useEffect 호출만 체크하기 때문에 경고해주지 않아요. Hook에 대해서는 알지 못하니까요.
Effect를 작성하고 있다면, React API를 직접 사용하는 것부터 시작하세요:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ 좋아요: 목적별로 분리된 두 개의 원시 Effect
useEffect(() => {
const connection = createConnection({ serverUrl, roomId });
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
useEffect(() => {
post('/analytics/event', { eventName: 'visit_chat', roomId });
}, [roomId]);
// ...
}
그런 다음, 다른 고수준 사용 사례를 위해 커스텀 Hook을 추출할 수 있어요 (하지만 반드시 그럴 필요는 없어요):
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ 훌륭해요: 목적에 따라 이름 지어진 커스텀 Hook
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}
좋은 커스텀 Hook은 수행하는 작업을 제한해서 호출 코드를 더 선언적으로 만들어요. 예를 들어, useChatRoom(options)는 채팅방에만 연결할 수 있고, useImpressionLog(eventName, extraData)는 분석에 impression 로그만 보낼 수 있어요. 커스텀 Hook API가 사용 사례를 제한하지 않고 매우 추상적이라면, 장기적으로 해결하는 것보다 더 많은 문제를 일으킬 가능성이 높아요.
Effect는 "탈출구"예요: "React 밖으로 나가야" 할 때, 사용 사례에 더 나은 내장 솔루션이 없을 때 사용하죠. 시간이 지나면, React 팀의 목표는 더 구체적인 문제에 더 구체적인 솔루션을 제공해서 앱의 Effect 수를 최소화하는 거예요. Effect를 커스텀 Hook으로 감싸면 이런 솔루션이 제공될 때 코드를 업그레이드하기 쉬워져요.
이 예제로 돌아가 봐요:
import { useOnlineStatus } from './useOnlineStatus.js';
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
export default function App() {
return (
<>
<SaveButton />
<StatusBar />
</>
);
}
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
위 예제에서 useOnlineStatus는 useState와 useEffect 쌍으로 구현됐어요. 하지만 이게 최선의 솔루션은 아니에요. 고려하지 않은 여러 엣지 케이스가 있어요. 예를 들어, 컴포넌트가 마운트될 때 isOnline이 이미 true라고 가정하는데, 네트워크가 이미 오프라인이 됐다면 틀렸을 수 있어요. 브라우저 navigator.onLine API를 사용해서 체크할 수 있지만, 직접 사용하면 초기 HTML을 생성하는 서버에서는 작동하지 않아요. 요약하면, 이 코드는 개선될 수 있어요.
React는 이런 문제를 모두 처리해주는 useSyncExternalStore라는 전용 API를 포함하고 있어요. 이 새로운 API를 활용하도록 다시 작성된 useOnlineStatus Hook이에요:
import { useOnlineStatus } from './useOnlineStatus.js';
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
export default function App() {
return (
<>
<SaveButton />
<StatusBar />
</>
);
}
import { useSyncExternalStore } from 'react';
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine, // 클라이언트에서 값을 가져오는 방법
() => true // 서버에서 값을 가져오는 방법
);
}
이 마이그레이션을 하는 데 컴포넌트를 전혀 변경할 필요가 없었다는 걸 주목하세요:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
이게 Effect를 커스텀 Hook으로 감싸는 게 종종 유익한 또 다른 이유예요:
디자인 시스템처럼, 앱의 컴포넌트에서 공통 관용구를 커스텀 Hook으로 추출하는 게 도움이 될 거예요. 이렇게 하면 컴포넌트의 코드가 의도에 집중하고, 원시 Effect를 자주 작성하는 걸 피할 수 있어요. React 커뮤니티에서 유지 관리하는 훌륭한 커스텀 Hook이 많이 있어요.
현재, use API를 사용하면 Promise를 use에 전달해서 렌더링 중에 데이터를 읽을 수 있어요:
import { use, Suspense } from "react";
function Message({ messagePromise }) {
const messageContent = use(messagePromise);
return <p>Here is the message: {messageContent}</p>;
}
export function MessageContainer({ messagePromise }) {
return (
<Suspense fallback={<p>⌛Downloading message...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
);
}
아직 세부사항을 작업 중이지만, 미래에는 이런 식으로 데이터 페칭을 작성하게 될 거예요:
import { use } from 'react';
function ShippingForm({ country }) {
const cities = use(fetch(`/api/cities?country=${country}`));
const [city, setCity] = useState(null);
const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;
// ...
앱에서 위의 useData 같은 커스텀 Hook을 사용한다면, 모든 컴포넌트에서 수동으로 원시 Effect를 작성한 것보다 최종 권장 방식으로 마이그레이션하는 데 필요한 변경이 적을 거예요. 하지만 기존 방식도 여전히 잘 작동하니까, 원시 Effect를 작성하는 게 편하다면 계속 그렇게 해도 돼요.
브라우저 requestAnimationFrame API를 사용해서 처음부터 페이드인 애니메이션을 구현하고 싶다고 해봐요. 애니메이션 루프를 설정하는 Effect부터 시작할 수 있겠죠. 애니메이션의 각 프레임 동안 ref에 담은 DOM 노드의 opacity를 1이 될 때까지 바꿀 수 있어요. 코드는 이렇게 시작할 수 있어요:
import { useState, useEffect, useRef } from 'react';
function Welcome() {
const ref = useRef(null);
useEffect(() => {
const duration = 1000;
const node = ref.current;
let startTime = performance.now();
let frameId = null;
function onFrame(now) {
const timePassed = now - startTime;
const progress = Math.min(timePassed / duration, 1);
onProgress(progress);
if (progress < 1) {
// 아직 그릴 프레임이 더 있어요
frameId = requestAnimationFrame(onFrame);
}
}
function onProgress(progress) {
node.style.opacity = progress;
}
function start() {
onProgress(0);
startTime = performance.now();
frameId = requestAnimationFrame(onFrame);
}
function stop() {
cancelAnimationFrame(frameId);
startTime = null;
frameId = null;
}
start();
return () => stop();
}, []);
return (
<h1 className="welcome" ref={ref}>
Welcome
</h1>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
opacity: 0;
color: white;
padding: 50px;
text-align: center;
font-size: 50px;
background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}
컴포넌트를 더 읽기 쉽게 만들려면, 로직을 useFadeIn 커스텀 Hook으로 추출할 수 있어요:
// App.js
import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';
function Welcome() {
const ref = useRef(null);
useFadeIn(ref, 1000);
return (
<h1 className="welcome" ref={ref}>
Welcome
</h1>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
import { useEffect } from 'react';
export function useFadeIn(ref, duration) {
useEffect(() => {
const node = ref.current;
let startTime = performance.now();
let frameId = null;
function onFrame(now) {
const timePassed = now - startTime;
const progress = Math.min(timePassed / duration, 1);
onProgress(progress);
if (progress < 1) {
// 아직 그릴 프레임이 더 있어요
frameId = requestAnimationFrame(onFrame);
}
}
function onProgress(progress) {
node.style.opacity = progress;
}
function start() {
onProgress(0);
startTime = performance.now();
frameId = requestAnimationFrame(onFrame);
}
function stop() {
cancelAnimationFrame(frameId);
startTime = null;
frameId = null;
}
start();
return () => stop();
}, [ref, duration]);
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
opacity: 0;
color: white;
padding: 50px;
text-align: center;
font-size: 50px;
background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}
useFadeIn 코드를 그대로 둘 수도 있지만, 더 리팩토링할 수도 있어요. 예를 들어, 애니메이션 루프를 설정하는 로직을 useFadeIn에서 커스텀 useAnimationLoop Hook으로 추출할 수 있어요:
// App.js
import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';
function Welcome() {
const ref = useRef(null);
useFadeIn(ref, 1000);
return (
<h1 className="welcome" ref={ref}>
Welcome
</h1>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export function useFadeIn(ref, duration) {
const [isRunning, setIsRunning] = useState(true);
useAnimationLoop(isRunning, (timePassed) => {
const progress = Math.min(timePassed / duration, 1);
ref.current.style.opacity = progress;
if (progress === 1) {
setIsRunning(false);
}
});
}
function useAnimationLoop(isRunning, drawFrame) {
const onFrame = useEffectEvent(drawFrame);
useEffect(() => {
if (!isRunning) {
return;
}
const startTime = performance.now();
let frameId = null;
function tick(now) {
const timePassed = now - startTime;
onFrame(timePassed);
frameId = requestAnimationFrame(tick);
}
tick();
return () => cancelAnimationFrame(frameId);
}, [isRunning]);
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
opacity: 0;
color: white;
padding: 50px;
text-align: center;
font-size: 50px;
background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}
하지만 그렇게 할 필요는 없었어요. 일반 함수처럼, 궁극적으로 코드의 다른 부분 사이의 경계를 어디에 그을지는 여러분이 결정해요. 매우 다른 접근 방식을 취할 수도 있어요. Effect에 로직을 두는 대신, 대부분의 명령형 로직을 JavaScript 클래스 안으로 옮길 수도 있어요:
// App.js
import { useState, useEffect, useRef } from 'react';
import { useFadeIn } from './useFadeIn.js';
function Welcome() {
const ref = useRef(null);
useFadeIn(ref, 1000);
return (
<h1 className="welcome" ref={ref}>
Welcome
</h1>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
import { useState, useEffect } from 'react';
import { FadeInAnimation } from './animation.js';
export function useFadeIn(ref, duration) {
useEffect(() => {
const animation = new FadeInAnimation(ref.current);
animation.start(duration);
return () => {
animation.stop();
};
}, [ref, duration]);
}
export class FadeInAnimation {
constructor(node) {
this.node = node;
}
start(duration) {
this.duration = duration;
this.onProgress(0);
this.startTime = performance.now();
this.frameId = requestAnimationFrame(() => this.onFrame());
}
onFrame() {
const timePassed = performance.now() - this.startTime;
const progress = Math.min(timePassed / this.duration, 1);
this.onProgress(progress);
if (progress === 1) {
this.stop();
} else {
// 아직 그릴 프레임이 더 있어요
this.frameId = requestAnimationFrame(() => this.onFrame());
}
}
onProgress(progress) {
this.node.style.opacity = progress;
}
stop() {
cancelAnimationFrame(this.frameId);
this.startTime = null;
this.frameId = null;
this.duration = 0;
}
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
opacity: 0;
color: white;
padding: 50px;
text-align: center;
font-size: 50px;
background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
}
Effect는 React를 외부 시스템에 연결할 수 있게 해줘요. Effect 간에 더 많은 조정이 필요할수록 (예를 들어, 여러 애니메이션을 체이닝하려면), 위의 샌드박스처럼 Effect와 Hook에서 로직을 완전히 추출하는 게 더 합리적이에요. 그러면 추출한 코드가 "외부 시스템"이 되는 거예요. 이렇게 하면 React 외부로 옮긴 시스템에 메시지만 보내면 되기 때문에 Effect가 간단하게 유지돼요.
위의 예제들은 페이드인 로직을 JavaScript로 작성해야 한다고 가정해요. 하지만 이 특정 페이드인 애니메이션은 일반 CSS 애니메이션으로 구현하는 게 더 간단하고 훨씬 더 효율적이에요:
import { useState, useEffect, useRef } from 'react';
import './welcome.css';
function Welcome() {
return (
<h1 className="welcome">
Welcome
</h1>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
.welcome {
color: white;
padding: 50px;
text-align: center;
font-size: 50px;
background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%);
animation: fadeIn 1000ms;
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
때로는 Hook도 필요 없어요!
use 다음에 대문자로 시작하는 이름으로 지어야 해요.useMount 같은 커스텀 Hook을 만들지 마세요. 목적을 구체적으로 유지하세요.useCounter Hook 추출하기 {/extract-a-usecounter-hook/}이 컴포넌트는 state 변수와 Effect를 사용해서 매초 증가하는 숫자를 표시해요. 이 로직을 useCounter라는 커스텀 Hook으로 추출하세요. 목표는 Counter 컴포넌트 구현이 정확히 이렇게 보이도록 만드는 거예요:
export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}
커스텀 Hook을 useCounter.js에 작성하고 App.js 파일로 import해야 해요.
// App.js
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>Seconds passed: {count}</h1>;
}
// 이 파일에 커스텀 Hook을 작성하세요!
코드는 이렇게 보여야 해요:
// App.js
import { useCounter } from './useCounter.js';
export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}
import { useState, useEffect } from 'react';
export function useCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return count;
}
App.js가 더 이상 useState나 useEffect를 import할 필요가 없다는 걸 주목하세요.
이 예제에는 슬라이더로 제어되는 delay state 변수가 있지만, 그 값이 사용되지 않고 있어요. delay 값을 커스텀 useCounter Hook에 전달하고, useCounter Hook이 하드코딩된 1000 ms 대신 전달된 delay를 사용하도록 변경하세요.
// App.js
import { useState } from 'react';
import { useCounter } from './useCounter.js';
export default function Counter() {
const [delay, setDelay] = useState(1000);
const count = useCounter();
return (
<>
<label>
Tick duration: {delay} ms
<br />
<input
type="range"
value={delay}
min="10"
max="2000"
onChange={e => setDelay(Number(e.target.value))}
/>
</label>
<hr />
<h1>Ticks: {count}</h1>
</>
);
}
import { useState, useEffect } from 'react';
export function useCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return count;
}
useCounter(delay)로 Hook에 delay를 전달하세요. 그런 다음 Hook 내부에서 하드코딩된 1000 값 대신 delay를 사용하세요. Effect의 의존성에 delay를 추가해야 해요. 이렇게 하면 delay의 변경이 interval을 재설정할 거예요.
// App.js
import { useState } from 'react';
import { useCounter } from './useCounter.js';
export default function Counter() {
const [delay, setDelay] = useState(1000);
const count = useCounter(delay);
return (
<>
<label>
Tick duration: {delay} ms
<br />
<input
type="range"
value={delay}
min="10"
max="2000"
onChange={e => setDelay(Number(e.target.value))}
/>
</label>
<hr />
<h1>Ticks: {count}</h1>
</>
);
}
import { useState, useEffect } from 'react';
export function useCounter(delay) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, delay);
return () => clearInterval(id);
}, [delay]);
return count;
}
useCounter에서 useInterval 추출하기 {/extract-useinterval-out-of-usecounter/}현재 useCounter Hook은 두 가지 일을 해요. interval을 설정하고, interval 틱마다 state 변수를 증가시켜요. interval을 설정하는 로직을 useInterval이라는 별도의 Hook으로 분리하세요. 두 개의 인자를 받아야 해요: onTick 콜백과 delay. 이 변경 후, useCounter 구현은 이렇게 보여야 해요:
export function useCounter(delay) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1);
}, delay);
return count;
}
useInterval을 useInterval.js 파일에 작성하고 useCounter.js 파일로 import하세요.
// App.js
import { useCounter } from './useCounter.js';
export default function Counter() {
const count = useCounter(1000);
return <h1>Seconds passed: {count}</h1>;
}
import { useState, useEffect } from 'react';
export function useCounter(delay) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, delay);
return () => clearInterval(id);
}, [delay]);
return count;
}
// 여기에 Hook을 작성하세요!
useInterval 내부의 로직은 interval을 설정하고 정리해야 해요. 다른 건 할 필요 없어요.
// App.js
import { useCounter } from './useCounter.js';
export default function Counter() {
const count = useCounter(1000);
return <h1>Seconds passed: {count}</h1>;
}
import { useState } from 'react';
import { useInterval } from './useInterval.js';
export function useCounter(delay) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1);
}, delay);
return count;
}
import { useEffect } from 'react';
export function useInterval(onTick, delay) {
useEffect(() => {
const id = setInterval(onTick, delay);
return () => clearInterval(id);
}, [onTick, delay]);
}
이 솔루션에는 약간의 문제가 있다는 걸 주목하세요. 다음 챌린지에서 해결할 거예요.
이 예제에는 두 개의 별도 interval이 있어요.
App 컴포넌트는 useCounter를 호출하고, 이건 매초 카운터를 업데이트하기 위해 useInterval을 호출해요. 하지만 App 컴포넌트는 또한 2초마다 무작위로 페이지 배경색을 업데이트하기 위해 useInterval을 호출해요.
어떤 이유에서인지 페이지 배경을 업데이트하는 콜백이 절대 실행되지 않아요. useInterval 내부에 로그를 추가해보세요:
useEffect(() => {
console.log('✅ Setting up an interval with delay ', delay)
const id = setInterval(onTick, delay);
return () => {
console.log('❌ Clearing an interval with delay ', delay)
clearInterval(id);
};
}, [onTick, delay]);
로그가 예상대로 나오나요? Effect 중 일부가 불필요하게 다시 동기화되는 것 같다면, 어떤 의존성이 그 원인인지 짐작할 수 있나요? Effect에서 그 의존성을 제거할 방법이 있나요?
문제를 고치고 나면, 페이지 배경이 2초마다 업데이트되길 기대해야 해요.
useInterval Hook이 이벤트 리스너를 인자로 받는 것 같네요. 이 이벤트 리스너를 감싸서 Effect의 의존성이 될 필요가 없도록 할 방법을 생각할 수 있나요?
// App.js
import { useCounter } from './useCounter.js';
import { useInterval } from './useInterval.js';
export default function Counter() {
const count = useCounter(1000);
useInterval(() => {
const randomColor = `hsla(${Math.random() * 360}, 100%, 50%, 0.2)`;
document.body.style.backgroundColor = randomColor;
}, 2000);
return <h1>Seconds passed: {count}</h1>;
}
import { useState } from 'react';
import { useInterval } from './useInterval.js';
export function useCounter(delay) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1);
}, delay);
return count;
}
import { useEffect } from 'react';
import { useEffectEvent } from 'react';
export function useInterval(onTick, delay) {
useEffect(() => {
const id = setInterval(onTick, delay);
return () => {
clearInterval(id);
};
}, [onTick, delay]);
}
useInterval 내부에서 이 페이지 앞부분에서 했던 것처럼 tick 콜백을 Effect Event로 감싸세요.
이렇게 하면 Effect의 의존성에서 onTick을 생략할 수 있어요. Effect가 컴포넌트의 매 리렌더링마다 다시 동기화되지 않을 테니까, 페이지 배경색 변경 interval이 발동할 기회를 갖기 전에 매초마다 재설정되지 않을 거예요.
이 변경으로 두 interval 모두 예상대로 작동하고 서로 방해하지 않아요:
// App.js
import { useCounter } from './useCounter.js';
import { useInterval } from './useInterval.js';
export default function Counter() {
const count = useCounter(1000);
useInterval(() => {
const randomColor = `hsla(${Math.random() * 360}, 100%, 50%, 0.2)`;
document.body.style.backgroundColor = randomColor;
}, 2000);
return <h1>Seconds passed: {count}</h1>;
}
import { useState } from 'react';
import { useInterval } from './useInterval.js';
export function useCounter(delay) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1);
}, delay);
return count;
}
import { useEffect } from 'react';
import { useEffectEvent } from 'react';
export function useInterval(callback, delay) {
const onTick = useEffectEvent(callback);
useEffect(() => {
const id = setInterval(onTick, delay);
return () => clearInterval(id);
}, [delay]);
}
이 예제에서 usePointerPosition() Hook은 현재 포인터 위치를 추적해요. 미리보기 영역 위로 커서나 손가락을 움직여보면 빨간 점이 움직임을 따라가는 걸 볼 거예요. 위치는 pos1 변수에 저장돼요.
실제로 다섯 개(!)의 다른 빨간 점이 렌더링되고 있어요. 현재 모두 같은 위치에 나타나기 때문에 보이지 않는 거예요. 이게 고쳐야 할 부분이에요. 대신 구현하고 싶은 건 "엇갈린" 움직임이에요: 각 점이 이전 점의 경로를 "따라가야" 해요. 예를 들어, 커서를 빠르게 움직이면 첫 번째 점이 즉시 따라가고, 두 번째 점은 약간의 지연을 두고 첫 번째 점을 따라가고, 세 번째 점은 두 번째 점을 따라가고, 이런 식이어야 해요.
useDelayedValue 커스텀 Hook을 구현해야 해요. 현재 구현은 제공된 value를 그대로 반환해요. 대신 delay 밀리초 전의 값을 반환하고 싶어요. 이를 위해 state와 Effect가 필요할 수 있어요.
useDelayedValue를 구현한 후, 점들이 서로를 따라 움직이는 걸 볼 수 있어야 해요.
커스텀 Hook 내부에 delayedValue를 state 변수로 저장해야 해요. value가 변경되면 Effect를 실행하고 싶을 거예요. 이 Effect는 delay 후에 delayedValue를 업데이트해야 해요. setTimeout을 호출하는 게 도움이 될 거예요.
이 Effect에 cleanup이 필요한가요? 왜 필요하거나 필요하지 않나요?
// App.js
import { usePointerPosition } from './usePointerPosition.js';
function useDelayedValue(value, delay) {
// TODO: 이 Hook을 구현하세요
return value;
}
export default function Canvas() {
const pos1 = usePointerPosition();
const pos2 = useDelayedValue(pos1, 100);
const pos3 = useDelayedValue(pos2, 200);
const pos4 = useDelayedValue(pos3, 100);
const pos5 = useDelayedValue(pos3, 50);
return (
<>
<Dot position={pos1} opacity={1} />
<Dot position={pos2} opacity={0.8} />
<Dot position={pos3} opacity={0.6} />
<Dot position={pos4} opacity={0.4} />
<Dot position={pos5} opacity={0.2} />
</>
);
}
function Dot({ position, opacity }) {
return (
<div style={{
position: 'absolute',
backgroundColor: 'pink',
borderRadius: '50%',
opacity,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: 'none',
left: -20,
top: -20,
width: 40,
height: 40,
}} />
);
}
import { useState, useEffect } from 'react';
export function usePointerPosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
}, []);
return position;
}
body { min-height: 300px; }
작동하는 버전이에요. delayedValue를 state 변수로 유지해요. value가 업데이트되면, Effect가 delayedValue를 업데이트하도록 timeout을 예약해요. 그래서 delayedValue가 항상 실제 value보다 "뒤처지게" 되는 거예요.
// App.js
import { useState, useEffect } from 'react';
import { usePointerPosition } from './usePointerPosition.js';
function useDelayedValue(value, delay) {
const [delayedValue, setDelayedValue] = useState(value);
useEffect(() => {
setTimeout(() => {
setDelayedValue(value);
}, delay);
}, [value, delay]);
return delayedValue;
}
export default function Canvas() {
const pos1 = usePointerPosition();
const pos2 = useDelayedValue(pos1, 100);
const pos3 = useDelayedValue(pos2, 200);
const pos4 = useDelayedValue(pos3, 100);
const pos5 = useDelayedValue(pos3, 50);
return (
<>
<Dot position={pos1} opacity={1} />
<Dot position={pos2} opacity={0.8} />
<Dot position={pos3} opacity={0.6} />
<Dot position={pos4} opacity={0.4} />
<Dot position={pos5} opacity={0.2} />
</>
);
}
function Dot({ position, opacity }) {
return (
<div style={{
position: 'absolute',
backgroundColor: 'pink',
borderRadius: '50%',
opacity,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: 'none',
left: -20,
top: -20,
width: 40,
height: 40,
}} />
);
}
import { useState, useEffect } from 'react';
export function usePointerPosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
}, []);
return position;
}
body { min-height: 300px; }
이 Effect는 cleanup이 필요하지 않다는 걸 주목하세요. cleanup 함수에서 clearTimeout을 호출한다면, value가 변경될 때마다 이미 예약된 timeout을 재설정할 거예요. 움직임을 연속적으로 유지하려면, 모든 timeout이 발동되길 원해요.