Escape Hatches : 커스텀 Hook으로 로직 재사용하기
React에는 useState, useContext, useEffect와 같은 몇 가지 내장 Hook이 있습니다. 때로는 데이터를 가져오거나, 사용자가 온라인 상태인지 추적하거나, 채팅방에 연결하는 등 좀 더 구체적인 목적을 위한 Hook이 있었으면 좋겠다는 생각이 들 때가 있습니다. React에서 이러한 Hook을 찾지 못할 수도 있지만 애플리케이션의 필요에 따라 자신만의 Hook을 만들 수 있습니다.
학습 내용
- 커스텀 Hook의 정의와 직접 작성하는 방법
- 컴포넌트 간에 로직을 재사용하는 방법
- 커스텀 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>;
}
네트워크를 켜고 끄고 동작에 따라 이 상태 표시줄이 어떻게 업데이트되는지 확인해 보세요.
이제 다른 컴포넌트에서도 동일한 로직을 사용하고 싶다고 가정해 봅시다. 네트워크가 꺼져 있을 때 비활성화되고 "Save" 대신 "Reconnecting..."이 표시되는 저장 버튼을 구현하고 싶다고 가정해 보겠습니다.
시작하려면 isOnline 상태와 이펙트를 복사하여 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>
);
}
네트워크를 끄면 버튼의 모양이 바뀌는지 확인합니다.
이 두 구성 요소는 잘 작동하지만 두 구성 요소 간의 로직이 중복되는 것은 아쉽습니다. 시각적 모양은 다르지만 그 사이의 로직을 재사용하고 싶은 것 같습니다.
컴포넌트에서 나만의 커스텀 Hook 추출하기
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을 반환합니다. 이렇게 하면 컴포넌트가 해당 값을 읽을 수 있습니다:
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 />
</>
);
}
네트워크를 켜고 끄면 두 컴포넌트가 모두 업데이트되는지 확인합니다.
이제 컴포넌트에는 반복적인 로직이 많지 않습니다. 더 중요한 것은, 컴포넌트 내부의 코드가 브라우저 이벤트에 가입하여 수행하는 방법보다는 수행하려는 작업(온라인 상태 사용!)을 설명한다는 것입니다.
로직을 커스텀 Hook으로 추출하면 외부 시스템이나 브라우저 API를 처리하는 방법에 대한 지저분한 세부 사항을 숨길 수 있습니다. 컴포넌트의 코드는 구현이 아니라 의도를 표현합니다.
훅 이름은 항상 use로 시작 해야 합니다.
React 애플리케이션은 컴포넌트로 빌드됩니다. 컴포넌트는 내장된 것이든 사용자 정의한 것이든 Hook으로 빌드됩니다. 다른 사람이 만든 커스텀 Hook을 사용하는 경우가 많지만, 가끔은 직접 작성할 수도 있습니다!
다음 명명 규칙을 따라야 합니다:
- React 컴포넌트 이름은 StatusBar, SaveButton과 같이 대문자로 시작해야 합니다. 또한 React 컴포넌트는 JSX와 같이 React가 표시하는 방법을 알고 있는 것을 반환해야 합니다.
- 훅 이름은 useState(내장) 또는 useOnlineStatus(페이지 앞부분과 같은 사용자 정의)와 같이 대문자로 시작해야 합니다. Hook은 임의의 값을 반환할 수 있습니다.
이 규칙은 컴포넌트를 보고 state, Effect 및 기타 React 기능이 어디에 "숨어 있는지" 항상 알 수 있도록 보장합니다. 예를 들어 컴포넌트 내부에 getColor() 함수 호출이 있다면, 그 이름이 use로 시작하지 않기 때문에 내부에 React state가 포함될 수 없다는 것을 확신할 수 있습니다. 하지만 useOnlineStatus()와 같은 함수 호출은 내부에 다른 Hook에 대한 호출을 포함할 가능성이 높습니다!
Note
린터가 React용으로 구성된 경우, 이 명명 규칙을 적용합니다. 위의 샌드박스로 스크롤하여 useOnlineStatus의 이름을 getOnlineStatus로 변경해보세요. 이제 더는 내부에서 useState나 useEffect를 호출할 수 없다는 것을 알 수 있습니다. 오직 Hook과 컴포넌트만이 다른 Hook을 호출할 수 있습니다!
Deep Dive - 렌더링 중에 호출되는 모든 함수는 use 접두사로 시작해야 하나요?
아니요. Hook을 호출하지 않는 함수는 Hook일 필요가 없습니다.
함수가 Hook을 호출하지 않는다면 use 접두사를 사용하지 마세요. 대신 use 접두사가 없는 일반 함수로 작성하세요. 예를 들어, 아래의 useSorted는 Hook을 호출하지 않으므로 대신 getSorted로 호출하세요:
// 🔴 Avoid: A Hook that doesn't use Hooks
function useSorted(items) {
return items.slice().sort();
}
// ✅ Good: A regular function that doesn't use Hooks
function getSorted(items) {
return items.slice().sort();
}
이렇게 하면 코드가 조건을 포함하여 어디서나 이 일반 함수를 호출할 수 있습니다:
function List({ items, shouldSort }) {
let displayedItems = items;
if (shouldSort) {
// ✅ getSorted()는 Hook이 아니므로 조건부로 호출해도 괜찮습니다.
displayedItems = getSorted(items);
}
// ...
}
함수가 내부에 하나 이상의 Hook을 사용하는 경우 함수에 use 접두사를 지정해야 합니다(따라서 Hook으로 만들어야 합니다):
// ✅ Good: A Hook that uses other Hooks
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 상태 변수가 두 컴포넌트 간에 공유된다고 생각하는 것은 잘못된 생각입니다. 이 코드를 보세요:
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(() => {
// ...
}, []);
// ...
}
완전히 독립적인 두 개의 상태 변수와 이펙트입니다! 네트워크가 켜져 있는지 여부에 관계없이 동일한 외부 값으로 동기화했기 때문에 동시에 동일한 값을 갖게 된 것입니다.
이를 더 잘 설명하기 위해 다른 예시가 필요합니다. 이 폼 컴포넌트를 생각해 봅시다:
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>
</>
);
}
각 양식 필드에는 몇 가지 반복적인 로직이 있습니다:
- state(firstname과 lastName)가 있습니다.
- change handler(handleFirstNameChange 및 handleLastNameChange)가 있습니다.
- 해당 입력에 대한 value와 onChange 속성을 지정하는 JSX 조각이 있습니다.
반복 로직을 이 useFormInput 커스텀 Hook으로 추출할 수 있습니다:
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;
}
value라는 state 변수를 하나만 선언하는 것을 알 수 있습니다.
하지만 Form 컴포넌트는 useFormInput을 두 번 호출합니다:
function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...
이것이 바로 두 개의 상태 변수를 선언하는 것처럼 작동하는 이유입니다!
커스텀 Hook을 사용하면 상태 로직을 공유할 수 있지만 상태 자체는 공유할 수 없습니다. Hook에 대한 각 호출은 동일한 Hook에 대한 다른 모든 호출과 완전히 독립적입니다. 이것이 위의 두 샌드박스가 완전히 동일한 이유입니다. 원하신다면 스크롤을 위로 올려서 비교해보세요. 커스텀 Hook을 추출하기 전과 후의 동작은 동일합니다.
여러 컴포넌트 간에 상태 자체를 공유해야 하는 경우, 대신 상태를 들어 올려서 전달하세요.
Hook 사이에 반응형 값 전달하기
컴포넌트를 다시 렌더링할 때마다 커스텀 Hook 내부의 코드가 다시 실행됩니다. 그렇기 때문에 컴포넌트와 마찬가지로 커스텀 Hook도 순수해야 합니다. 커스텀 Hook의 코드를 컴포넌트 본문의 일부로 생각하세요!
커스텀 Hook은 컴포넌트와 함께 리렌더링되기 때문에 항상 최신 props와 state를 받습니다. 이것이 무엇을 의미하는지 이 채팅방 예시를 통해 알아보세요. serverURL이나 채팅방을 변경합니다:
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>
</>
);
}
serverUrl 또는 roomId를 변경하면 이펙트가 변경 사항에 '반응'하여 다시 동기화됩니다. 이펙트의 종속성을 변경할 때마다 채팅이 다시 연결된다는 콘솔 메시지를 통해 알 수 있습니다.
이제 이펙트의 코드를 커스텀 훅으로 옮깁니다:
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>
</>
);
}
훨씬 더 간단해 보입니다! (하지만 같은 일을 합니다.)
로직이 여전히 props와 state 변경에 반응하는 것을 확인할 수 있습니다. 서버 URL이나 선택한 방을 수정해 보세요:
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>
</>
);
}
하나의 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의 입력에 "피드"되는 것과 같습니다).
이벤트 핸들러를 커스텀 Hook에 전달하기
개발 중
이 섹션에서는 아직 안정된 버전의 React로 출시되지 않은 실험적인 API에 대해 설명합니다.
더 많은 컴포넌트에서 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]); // ✅ All dependencies declared
}
이 방법도 작동하지만 사용자 정의 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]); // ✅ All dependencies declared
}
이제 ChatRoom 컴포넌트가 다시 렌더링할 때마다 채팅이 다시 연결되지 않습니다. 다음은 이벤트 핸들러를 커스텀 Hook에 전달하는 데모입니다:
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>
</>
);
}
이제 더 이상 사용 채팅방이 어떻게 작동하는지 알 필요 없이 사용할 수 있습니다. 다른 컴포넌트에 추가하고 다른 옵션을 전달해도 동일한 방식으로 작동합니다. 이것이 바로 커스텀 Hook의 힘입니다.
커스텀 Hook을 사용하는 경우
코드가 조금이라도 중복될 때마다 커스텀 Hook을 추출할 필요는 없습니다. 약간의 중복은 괜찮습니다. 예를 들어, 앞서와 같이 단일 useState 호출을 감싸기 위해 useFormInput Hook을 추출하는 것은 불필요할 수 있습니다.
하지만 Effect를 작성할 때마다 커스텀 Hook으로 감싸는 것이 더 명확할지 고려하세요. Effect가 자주 필요하지 않을 것이므로, 만약 Effect를 작성한다면 외부 시스템과 동기화하거나 React에 내장된 API가 없는 작업을 수행하기 위해 "React 외부로 나가야 한다"는 것을 의미합니다. 이를 커스텀 Hook으로 감싸면 의도와 데이터 흐름 방식을 정확하게 전달할 수 있습니다.
예를 들어, 도시 목록을 표시하는 드롭다운과 선택한 도시의 지역 목록을 표시하는 드롭다운 두 개를 표시하는 ShippingForm 컴포넌트를 생각해 봅시다. 다음과 같은 코드로 시작할 수 있습니다:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// This Effect fetches cities for a country
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);
// This Effect fetches areas for the selected city
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로 병합해서는 안 됩니다. 대신, 위의 ShippingForm 컴포넌트 사이의 공통 로직을 자체 useData 데이터 훅으로 추출하여 단순화할 수 있습니다:
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을 입력하면 데이터를 가져올 수 있습니다. useData 안에 Effect를 "숨기면" ShippingForm 컴포넌트에서 작업하는 사람이 불필요한 종속성을 추가하는 것을 방지할 수 있습니다. 시간이 지나면 앱의 Effect 대부분이 커스텀 Hook에 포함될 것입니다.
Deep Dive - 사용자 정의 Hook을 구체적인 고급 사용 사례에 집중하세요.
먼저 커스텀 Hook의 이름을 결정하세요. 명확한 이름을 고르는 데 어려움을 겪는다면 이펙트가 컴포넌트의 나머지 로직과 너무 결합되어 있어 아직 추출할 준비가 되지 않았다는 의미일 수 있습니다.
커스텀 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)
예를 들어, 이 사용 마운트 훅은 일부 코드가 "마운트 시"에만 실행되도록 시도합니다:
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 에 누락된 의존성: 'fn'
}
useMount와 같은 커스텀 "라이프사이클" Hook은 React 패러다임에 잘 맞지 않습니다. 예를 들어, 이 코드 예시에는 실수가 있지만(roomId 또는 serverUrl 변경에 "반응"하지 않음), 린터는 직접 useEffect 호출만 검사하기 때문에 경고하지 않습니다. Hook에 대해서는 알지 못합니다.
Effect를 작성하는 경우 React API를 직접 사용하는 것부터 시작하세요:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ 좋음: 목적별로 구분된 두 가지 원시 효과
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)는 애널리틱스에 노출 로그만 전송할 수 있습니다. 사용자 정의 Hook API가 사용 사례를 제한하지 않고 매우 추상적이라면 장기적으로는 해결하는 것보다 더 많은 문제를 일으킬 수 있습니다.
커스텀 Hook 은 더 나은 패턴으로 마이그레이션하는 데 도움이 됩니다.
Effect는 "탈출구"입니다. "React 외부로 나가야 할 때", 그리고 사용 사례에 더 나은 내장 솔루션이 없을 때 사용합니다. 시간이 지남에 따라 React 팀의 목표는 더 구체적인 문제에 대한 더 구체적인 솔루션을 제공함으로써 앱의 Effect 수를 최소한으로 줄이는 것입니다. Effects를 커스텀 Hook으로 감싸면 이러한 솔루션이 제공될 때 코드를 더 쉽게 업그레이드할 수 있습니다.
이 예제로 돌아가 보겠습니다:
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 18에는 이러한 모든 문제를 처리하는 useSyncExternalStore라는 전용 API가 포함되어 있습니다. 다음은 이 새로운 API를 활용하기 위해 재작성된 useOnlineStatus Hook의 방법입니다:
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를 래핑하는 것이 종종 유익한 또 다른 이유입니다:
- 이펙트를 오가는 데이터 흐름을 매우 명확하게 만들 수 있습니다.
- 컴포넌트가 Effect의 정확한 구현보다는 의도에 집중할 수 있습니다.
- React에 새로운 기능이 추가되면 컴포넌트를 변경하지 않고도 해당 효과를 제거할 수 있습니다.
디자인 시스템과 유사하게, 앱의 컴포넌트에서 공통된 관용구를 추출하여 사용자 정의 Hook으로 만드는 것이 도움이 될 수 있습니다. 이렇게 하면 컴포넌트의 코드가 의도에 집중할 수 있고, 원시 Effect를 자주 작성하지 않아도 됩니다. React 커뮤니티에서 많은 훌륭한 커스텀 Hook을 관리하고 있습니다.
Deep Dive - React는 데이터 불러오기를 위한 빌트인 솔루션을 제공하나요?
아직 세부적인 사항을 작업 중이지만, 앞으로는 이와 같은 데이터 가져오기를 작성하게 될 것으로 예상합니다:
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;
// ...
앱에서 위의 사용데이터와 같은 커스텀 훅을 사용하면 모든 컴포넌트에 원시 Effect를 수동으로 작성하는 것보다 최종적으로 권장되는 접근 방식으로 마이그레이션하는 데 더 적은 변경이 필요합니다. 하지만 이전 접근 방식도 여전히 잘 작동하므로 원시 Effect를 작성하는 것이 만족스럽다면 계속 사용할 수 있습니다.
더 다양한 방법이 있습니다.
브라우저 요청AnimationFrame API를 사용하여 페이드인 애니메이션을 처음부터 구현한다고 가정해 보겠습니다. 애니메이션 루프를 설정하는 효과로 시작할 수 있습니다. 애니메이션의 각 프레임 동안 참조로 유지하는 DOM 노드의 불투명도를 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) {
// We still have more frames to paint
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 />}
</>
);
}
컴포넌트의 가독성을 높이기 위해 로직을 useFadeIn 커스텀 Hook으로 추출할 수 있습니다:
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 />}
</>
);
}
useFadeIn 코드를 그대로 유지할 수도 있지만 더 리팩터링할 수도 있습니다. 예를 들어, 애니메이션 루프를 설정하는 로직을 useFadeIn에서 추출하여 사용자 정의 useAnimationLoop Hook으로 만들 수 있습니다:
import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as 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]);
}
하지만 꼭 그렇게 할 필요는 없습니다. 일반 함수와 마찬가지로 궁극적으로 코드의 여러 부분 사이의 경계를 어디에 그릴지는 사용자가 결정합니다. 매우 다른 접근 방식을 취할 수도 있습니다. Effect에 로직을 유지하는 대신 대부분의 명령형 로직을 JavaScript 클래스 내부로 옮길 수 있습니다:
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]);
}
Effects를 사용하면 React를 외부 시스템에 연결할 수 있습니다. 예를 들어 여러 애니메이션을 체인으로 연결하는 등 Effect 간의 조정이 더 많이 필요할수록 위의 샌드박스처럼 Effect와 Hook에서 해당 로직을 완전히 추출하는 것이 더 합리적입니다. 그러면 추출한 코드가 "외부 시스템"이 됩니다. 이렇게 하면 React 외부로 이동한 시스템으로 메시지를 보내기만 하면 되기 때문에 Effects를 단순하게 유지할 수 있습니다.
위의 예시에서는 페이드인 로직이 자바스크립트로 작성되어야 한다고 가정했습니다. 하지만 이 특정 페이드인 애니메이션은 일반 CSS 애니메이션으로 구현하는 것이 더 간단하고 훨씬 더 효율적입니다:
.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 xq
0% { opacity: 0; }
100% { opacity: 1; }
}
때로는 후크가 필요 없을 때도 있습니다!
요약
- 커스텀 훅을 사용하면 컴포넌트 간에 로직을 공유할 수 있습니다.
- 커스텀 Hook의 이름은 use로 시작하고 대문자가 와야 합니다.
- 커스텀 Hook은 상태 저장 로직만 공유하며 상태 자체는 공유하지 않습니다.
- 한 Hook에서 다른 Hook으로 반응형 값을 전달할 수 있으며, 반응형 값은 최신 상태로 유지됩니다.
- 컴포넌트가 다시 렌더링될 때마다 모든 Hook이 다시 실행됩니다.
- 커스텀 Hook의 코드는 컴포넌트의 코드와 같이 순수해야 합니다.
- 커스텀 Hook이 수신한 이벤트 핸들러를 Effect Event로 래핑하세요.
- useMount와 같은 커스텀 Hook을 만들지 마세요. 용도를 명확히 하세요.
- 코드의 경계를 선택하는 방법과 위치는 여러분이 결정할 수 있습니다.
과제 1/5: useCounter 훅 추출하기
이 컴포넌트는 상태 변수와 Effect를 사용해 매초마다 증가하는 숫자를 표시합니다. 이 로직을 사용Counter라는 사용자 정의 Hook으로 추출합니다. 여러분의 목표는 카운터 컴포넌트 구현을 다음과 같이 만드는 것입니다:
export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}
https://codesandbox.io/s/extract-a-usecounter-hook-5werv8?file=/App.js
과제 2/5: 카운터 지연을 구성 가능하게 만들기
이 예시에서는 슬라이더로 제어되는 지연 상태 변수가 있지만 그 값은 사용되지 않습니다. 지연 값을 사용자 정의 사용 카운터 훅에 전달하고, 1000ms를 하드코딩하는 대신 전달된 지연을 사용하도록 사용 카운터 훅을 변경합니다.
https://codesandbox.io/s/make-the-counter-delay-configurable-bl4xdx?file=/App.js
과제 3/5: useCounter에서 useInterval 추출하기
현재 useCounter훅은 두 가지 작업을 수행합니다. 간격을 설정하고 간격이 틱될 때마다 상태 변수를 증가시킵니다. 간격을 설정하는 로직을 useInterval이라는 별도의 Hook으로 분리하세요. 이 Hook은 두 개의 인수를 받아야 합니다: onTick 콜백 및 지연. 이렇게 변경하면 useCounter 구현은 다음과 같이 보일 것입니다:
export function useCounter(delay) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1);
}, delay);
return count;
}
과제 4/5: 재설정 간격 수정하기
이 예시에서는 두 개의 별도 간격이 있습니다.
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]);
로그가 예상한 것과 일치하나요? 일부 이펙트가 불필요하게 재동기화되는 것 같다면 어떤 종속성 때문에 그런 일이 발생하는지 짐작할 수 있나요? 이펙트에서 해당 종속성을 제거할 수 있는 방법이 있나요?
문제를 해결한 후에는 페이지 배경이 2초마다 업데이트될 것으로 예상됩니다.
https://codesandbox.io/s/fix-a-resetting-interval-1ygmw4?file=/useInterval.js
과제 5/5: 비틀거리는 움직임 구현하기
이 예시에서는 usePointerPosition() Hook이 현재 포인터 위치를 추적합니다. 커서나 손가락을 미리보기 영역 위로 이동하면 빨간색 점이 움직임을 따라가는 것을 확인할 수 있습니다. 그 위치는 pos1 변수에 저장됩니다.
실제로는 다섯 개(!)의 다른 빨간색 점이 렌더링되고 있습니다. 현재는 모두 같은 위치에 나타나기 때문에 보이지 않습니다. 이 부분을 수정해야 합니다. 대신 구현하려는 것은 "엇갈린" 움직임으로, 각 점이 이전 점의 경로를 "따라야" 합니다. 예를 들어 커서를 빠르게 이동하면 첫 번째 점은 즉시 따라가고, 두 번째 점은 약간의 지연을 두고 첫 번째 점을 따라가고, 세 번째 점은 두 번째 점을 따라가는 등의 방식으로 커서를 이동해야 합니다.
사용 지연된 값 사용자 정의 Hook을 구현해야 합니다. 현재 구현은 제공된 값을 반환합니다. 대신 밀리초 전 지연에서 값을 다시 반환하고 싶습니다. 이를 위해서는 state와 Effect가 필요할 수 있습니다.
사용 지연된 값을 구현하고 나면 점들이 서로 따라 움직이는 것을 볼 수 있을 것입니다.
https://codesandbox.io/s/implement-a-staggering-movement-9ypgsg?file=/App.js
발표요약)
1. 커스텀 Hook은 use로 시작하고, 카멜케이스로 작명한다. ex) useScrollPosition
2. 커스텀 Hook은 상태값을 공유하지않고 '로직'을 공유한다.
3. 커스텀 Hook이 받는 매개변수의 이벤트 핸들러는 Effect Event로 래핑하자. 렌더링될 때마다 Effect내부 로직이 재실행되어 의도한대로 작동하지 않는다. (무한루프에 빠지기도 한다). 하지만 리액트 18버전 이상에서만 사용할수있다.
그런데 왜 종속성에 이벤트 핸들러가 있으면 무한루프에 빠지는걸까?
매개변수로 받은, 함께 선언한 반응형 값(유동값)이 디펜던시에 같이 걸리면 왜 무한루프에 빠짐?
=> ? 훅이 실행될때 함수는 이전 함수와 같다고 판단하지 않기때문에,,
'[STUDY] 스터디 > React Docs' 카테고리의 다른 글
리액트 공식문서 스터디 회고 (0) | 2023.04.10 |
---|---|
[7주차] 리액트 공식문서 한글 번역 : Removing Effect Dependencies - 이펙트 종속성 제거하기 (0) | 2023.04.07 |
[6주차] 리액트 공식문서 한글 번역 : Separating Events from Effects - 이벤트와 Effect 분리하기 (0) | 2023.04.07 |
[6주차] 리액트 공식문서 한글 번역 : Lifecycle of Reactive Effects- 유동적인 Effect의 라이프사이클 (0) | 2023.04.05 |
[6주차] 리액트 공식문서 한글 번역 : You Might Not Need an Effect- Effect는 필요 없을수도 있다 (0) | 2023.04.04 |