커스텀 Hook으로 로직 재사용하기

React는 useState, useContext, 그리고 useEffect같이 몇몇 내재하고 있는 Hook이 존재합니다. 가끔 조금 더 구체적인 목적을 가진 Hook이 존재하길 바랄 때도 있을 겁니다. 예를 들어, 데이터를 가져온다던가, 사용자가 온라인 상태인지 계속 확인한다던가, 혹은 채팅방에 연결하기 위한 목적들처럼요. React에서 다음과 같은 Hook들을 찾기는 어려울 것입니다. 하지만 애플리케이션의 필요에 알맞은 본인만의 Hook을 만들 수 있습니다.

학습 내용

  • 커스텀 Hook이 무엇이고, 어떻게 본인만의 Hook을 작성하는 지
  • 컴포넌트 간 로직을 재사용하는 방법
  • 나만의 커스텀 Hook 이름 짓기와 구조 잡기
  • 언제 그리고 왜 커스텀 Hook을 추출해야 하는지

커스텀 Hook: 컴포넌트간 로직 공유하기

네트워크에 크게 의존하는 앱 (대부분의 앱이 그렇듯)을 개발 중이라고 생각해 보세요. 유저가 앱을 사용하는 동안 네트워크가 갑자기 사라진다면, 유저에게 경고하고 싶을 겁니다. 이런 경우 어떻게 하실 건가요? 컴포넌트에는 다음 두 가지가 필요할 것입니다.

  1. 네트워크가 온라인 상태인지 아닌지 추적하는 하나의 state
  2. 전역 online (온라인), offline (오프라인) 이벤트를 구독하고, 이에 맞춰 state를 업데이트하는 Effect

두 가지 요소는 컴포넌트가 네트워크 상태와 동기화 되도록 합니다. 다음과 같이 구현할 수 있습니다.

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 ? '✅ 온라인' : '❌ 연결 안 됨'}</h1>;
}

네트워크를 껐다 켰다 해보세요. 그리고 StatusBar 가 어떻게 업데이트되는지 확인해 보세요.

이제 다른 컴포넌트에서 같은 로직을 사용한다고 상상해 보세요. 네트워크가 꺼졌을 때, “저장” 대신 “재연결 중…”을 보여주는 비활성화된 저장 버튼을 구현하고 싶다고 가정해 봅시다.

구현하기 위해, 앞서 사용한 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('✅ 진행사항 저장됨');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? '진행사항 저장' : '재연결 중...'}
    </button>
  );
}

네트워크를 껐을 때, 버튼의 모양이 바뀌는지 확인해 봅시다.

위의 두 컴포넌트는 잘 동작합니다. 하지만 둘 사이의 로직이 중복되는 점은 아쉽습니다. 두 컴포넌트가 다른 시각적 모양을 갖고 있다고 해도, 둘 사이의 로직을 재사용하길 원합니다.

컴포넌트로부터 커스텀 Hook 추출하기

useState 그리고 useEffect와 비슷한 내장된 useOnlineStatus Hook이 있다고 상상해 봅시다. 그럼 두 컴포넌트를 단순화할 수 있고, 둘 간의 중복을 제거할 수 있게 됩니다.

function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ 온라인' : '❌ 연결 안 됨'}</h1>;
}

function SaveButton() {
const isOnline = useOnlineStatus();

function handleSaveClick() {
console.log('✅ 진행사항 저장됨');
}

return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? '진행사항 저장' : '재연결 중...'}
</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 ? '✅ 온라인' : '❌ 연결 안 됨'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ 진행사항 저장됨');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? '진행사항 저장' : '재연결 중...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <SaveButton />
      <StatusBar />
    </>
  );
}

네트워크에 따라 두 컴포넌트가 업데이트되는지 확인해 봅시다.

이제 컴포넌트는 반복되는 로직이 많지 않게 되었습니다. 중요한 건, 두 컴포넌트 내부 코드가 어떻게 그것을 하는지 (브라우저 이벤트 구독하기) 보다 그들이 무엇을 하려는지 (온라인 state 사용하기)에 대해 설명하고 있다는 점입니다.

커스텀 Hook을 만들어 낼 때, 브라우저 API나 외부 시스템과 소통하는 방법과 같은 불필요한 세부 사항을 숨길 수 있습니다. 컴포넌트의 코드는 목적만을 나타낼 뿐 실행 방법에 대해선 나타내지 않습니다.

Hook의 이름은 항상 use로 시작해야 합니다.

React 애플리케이션은 여러 컴포넌트로 만들어집니다. 컴포넌트들은 내장되거나 직접 작성한 Hook으로 만들어집니다. 종종 다른 사람들에 의해 만들어진 Hook을 사용했을 것입니다. 하지만 때에 따라 본인만의 Hook을 만들어야 할 때도 있습니다.

이때, 다음의 작명 규칙을 준수해야 합니다.

  1. React 컴포넌트의 이름은 항상 대문자로 시작해야 합니다. (예시 : StatusBar, SaveButton) 또한 React 컴포넌트는 JSX처럼 어떻게 보이는지 React가 알 수 있는 무언가를 반환해야 합니다.
  2. Hook의 이름은 use 뒤에 대문자로 시작해야 합니다. (예시 : useState (내장된 Hook) or useOnlineStatus (앞서 작성한 커스텀 Hook)) Hook들은 어떤 값이든 반환할 수 있습니다.

이런 규칙들은 컴포넌트를 볼 때, 어디에 state, Effect 및 다른 React 기능들이 “숨어” 있는지 알 수 있게 해줍니다. 예를 들어, 만약 컴포넌트 안에 getColor()라는 함수를 보았다면, 해당 함수의 이름이 use로 시작하지 않으므로 함수 안에 React state가 있을 수 없다는 것을 확신할 수 있습니다. 반대로 useOnlineStatus() 함수의 경우 높은 확률로 내부에 다른 Hook을 사용하고 있을 수 있습니다!

중요합니다!

linter가 React에 맞춰있다면, 작명 규칙을 지키게합니다. 위의 코드로 다시 올라가 useOnlineStatusgetOnlineStatus로 바꿔보세요. linter가 내부에서 useStateuseEffect를 사용하는 것을 더 이상 허용하지 않을 겁니다. 오로지 Hook과 컴포넌트만 다른 Hook을 사용할 수 있습니다!

Deep Dive

렌더링 중에 호출되는 모든 함수는 use 접두사로 시작해야 하나요?

아닙니다. 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: 인증이 수행될 때 해당 코드를 useContext(Auth)를 반환하는 코드로 바꾸기
return TEST_USER;
}

그럼, 컴포넌트는 조건에 따라 호출할 수 없게 됩니다. 이건 실제로 Hook을 내부에 추가해 호출할 때 매우 중요합니다. 지금이든 나중이든 Hook을 내부에서 사용할 계획이 없다면, Hook으로 만들지 마세요.

커스텀 Hook은 state 그 자체를 공유하는게 아닌 state 저장 로직을 공유하도록 합니다.

앞선 예시에서, 우리가 네트워크를 껐다 켰을 때 양쪽 컴포넌트가 함께 업데이트되었습니다. 그렇다고 해서 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가 있음을 확인할 수 있습니다. 그들은 우리가 동일한 외부 변수(네트워크의 연결 state)를 동기화했기 때문에 같은 시간에 같은 값을 가지고 있을 뿐입니다.

이걸 더 잘 표현하기 위해 다른 예시가 필요할 겁니다. 다음의 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>
    </>
  );
}

각각의 폼 입력에 반복되는 로직이 있습니다.

  1. state가 존재합니다. (firstNamelastName)
  2. 변화를 다루는 함수가 존재합니다. (handleFirstNameChangehandleLastNameChange).
  3. 해당 입력에 대한 valueonChange의 속성을 지정하는 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');
// ...

위의 예시는 왜 두 개의 다른 state 변수를 정의하는 식으로 동작하는지 보여줍니다.

커스텀 Hook은 우리가 state 그 자체가 아닌 state 저장 로직을 공유하도록 해줍니다. 같은 Hook을 호출하더라도 각각의 Hook 호출은 완전히 독립되어 있습니다. 이것이 위의 두 코드가 완전히 같은 이유입니다. 원한다면 위로 돌아가 비교해 보세요. 커스텀 Hook을 추출하기 전과 후가 동일합니다.

대신 여러 컴포넌트 간 state 자체를 공유할 필요가 있다면, state를 위로 올려 전달하세요.

Hook 사이에 상호작용하는 값 전달하기

커스텀 Hook 안의 코드는 컴포넌트가 재렌더링될 때마다 다시 돌아갈 겁니다. 이게 바로 커스컴 Hook이 (컴포넌트처럼) 순수해야하는 이유 입니다. 커스텀 Hook을 컴포넌트 본체의 한 부분이라고 생각하세요!

커스텀 Hook이 컴포넌트와 함께 재렌더링된다면, 항상 가장 최신의 props와 state를 전달받을 것입니다. 이게 무슨 말인지 살펴보기 위해 아래의 채팅방 예시를 확인해 보세요. 서버 URL이나 채팅방을 바꾼다고 생각해봅시다.

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>
    </>
  );
}

serverUrlroomId를 변경할 때, 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>
</>
);
}

매우 간단해졌습니다! (그런데도 똑같이 동작합니다)

로직이 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가 재렌더링될 때마다, Hook에 최신 roomIdserverUrl 값을 넘겨줍니다. 이게 바로 재렌더링 이후에 값이 달라지는지 여부에 관계없이 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]); // ✅ 모든 의존성이 정의됨.
}

이대로도 동작하지만, 커스텀 Hook이 이벤트 핸들러를 허용할 때 하나 더 개선할 수 있는 부분이 있습니다.

컴포넌트가 재렌더링될 때마다 채팅방을 재연결하는 원인이 되기 때문에, 의존성에 onReceiveMessage를 추가하는 것은 이상적이지 않습니다. 이 이벤트 핸들러를 의존성에서 제거하기 위해 Effect 이벤트로 감싸주세요.

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 { 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>
    </>
  );
}

이제 useChatRoom을 사용하기 위해 useChatRoom어떻게 동작하는지 알 필요가 없습니다. 다른 컴포넌트에 추가하거나, 다른 옵션을 전달해도 똑같이 동작할 겁니다. 이게 바로 커스텀 Hook의 힘입니다.

언제 커스텀 Hook을 사용해야 하는지

모든 자잘한 중복되는 코드들까지 커스텀 Hook으로 분리할 필요가 없습니다. 어떤 중복된 코드는 괜찮습니다. 예를 들어, 앞선 예시처럼 하나의 useState를 감싸기 위한 useFormInput을 분리하는 것은 불필요합니다.

하지만 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로 통합시킬 필요가 없습니다. 대신 ShippingForm 컴포넌트를 useData라는 커스텀 Hook을 통해 공통된 로직을 추출할 수 있습니다.

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를 받습니다. useData안의 Effect를 “숨김으로써” 다른 사람이 ShippingForm 컴포넌트에 불필요한 의존성을 추가하는 것을 막을 수 있습니다. 시간이 지나면 앱의 대부분 Effect들은 커스텀 Hook 안에 있을 겁니다.

Deep Dive

커스텀 Hook이 구체적인 고급 사용 사례에 집중하도록 하기

커스텀 Hook의 이름을 고르는 것부터 시작해 봅시다. 만약 명확한 이름을 고르기 위해 고군분투한다면, 그건 아마 사용하는 Effect가 컴포넌트 로직의 일부분에 너무 결합하여 있다는 의미일 겁니다. 그리고 아직 분리될 준비가 안 됐다는 뜻입니다.

이상적으로 커스텀 Hook의 이름은 코드를 자주 작성하는 사람이 아니더라도 커스텀 Hook이 무슨 일을 하고, 무엇을 props로 받고, 무엇을 반환하는지 알 수 있도록 아주 명확해야 합니다.

  • 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은 'fn'의 의존성을 갖고 있지 않음.
}

useMount과 같은 커스텀 “생명 주기” Hook은 전형적인 React와 맞지 않습니다. 예를 들어 이 코드 예시는 문제가 있지만(roomIdserverUrl의 변화에 반응하지 않음.), 린터는 오직 직접적인 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');

// ✅ Great: custom Hooks named after their purpose
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}

좋은 커스텀 Hook은 호출 코드가 하는 일을 제한하면서 좀 더 선언적으로 만들 수 있습니다. 예를 들어, useChatRoom(options)은 오직 채팅방과 연결할 수 있지만, useImpressionLog(eventName, extraData)은 애널리틱스에만 노출된 기록(Impression log)을 보낼 수 있습니다. 커스텀 Hook API가 사용 사례를 제한하지 않고 너무 추상적이라면, 장기적으로는 그것이 해결할 수 있는 것보다 더 많은 문제를 만들 가능성이 높습니다.

커스텀 Hook은 더 나은 패턴으로 변경할 수 있도록 도와줍니다.

Effect는 도피구 입니다. “React에서 벗어나”는 것이 필요할 때나 사용 시에 괜찮은 내장된 해결 방법이 없는 경우, 사용합니다. React 팀의 목표는 더 구체적인 문제에 더 구체적인 해결 방법을 제공해 앱에 있는 Effect의 숫자를 점차 최소한으로 줄이는 것입니다. 커스텀 Hook으로 Effect를 감싸는 것은 이런 해결 방법들이 가능해질 때 코드를 쉽게 업그레이드할 수 있게 해줍니다.

예시로 돌아가 봅시다.

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는 한 쌍의 useStateuseEffect와 함께 실행됩니다. 하지만 이건 가장 좋은 해결 방법은 아닙니다. 이 해결 방법이 고려하지 못한 수많은 예외 상황이 존재합니다. 예를 들어, 이건 컴포넌트가 마운트됐을 때, isOnline이 이미 true라고 가정합니다. 하지만 이것은 네트워크가 이미 꺼졌을 때 틀린 가정이 됩니다. 이런 상황을 확인하기 위해 브라우저 navigator.onLine API를 사용할 수도 있습니다. 하지만 이걸 직접적으로 사용하게 되면 초기 HTML을 생성하기 위한 서버에선 동작하지 않습니다. 짧게 말하면 코드는 보완되어야 합니다.

운 좋게도 React 18은 이런 모든 문제를 신경 써주는 useSyncExternalStore라고 불리는 섬세한 API를 포함합니다. 여기 새 API의 장점을 가지고 다시 쓰인 useOnlineStatus이 있습니다.

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();
// ...
}

커스텀 Hook으로 Effect를 감싸는 것이 종종 유용한 이유는 다음과 같습니다.

  1. 매우 명확하게 Effect로 주고받는 데이터 흐름을 만들 때
  2. 컴포넌트가 Effect의 정확한 실행보다 목적에 집중하도록 할 때
  3. React가 새 기능을 추가할 때, 다른 컴포넌트의 변경 없이 이 Effect를 삭제할 수 있을 때

디자인 시스템과 과 마찬가지로, 앱의 컴포넌트에서 일반적인 관용구를 추출하여 커스텀 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;
// ...

앱에 useData과 같은 커스텀 Hook을 사용한다면, 모든 컴포넌트에 수동으로 Effect를 작성하는 것보다 최종적으로 권장되는 접근 방식으로 변경하는 것이 더 적은 변경이 요구됩니다. 그러나 이전의 접근 방식도 충분히 잘 동작하기 때문에 Effect 사용을 즐긴다면 그렇게 사용해도 됩니다.

여러 방법이 존재합니다.

브라우저의 requestAnimationFrame API를 이용해 처음부터 페이드 인 애니메이션을 구현한다고 생각해 봅시다. 아마 애니메이션을 반복시키기 위해 Effect부터 작성할 겁니다. 각각의 애니메이션 프레임 동안 참조해 둔 ref 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) {
        // 아직 그려야 할 프레임이 많습니다.
        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 내부의 로직을 유지하는 대신, 대부분의 중요한 로직을 자바스크립트의 Class 내부로 이동시킬 수 있습니다.

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]);
}

Effect는 외부 시스템과 React를 연결할 수 있게 해줍니다. 예를 들어 여러 애니메이션을 연결하는 것처럼 Effects 간의 조정이 더 많이 필요할수록, 위의 코드 예시처럼 Effect와 Hook 밖으로 로직을 완전히 분리하는 것이 합리적입니다. 그렇게 분리한 코드는 “외부 시스템”이 될 것입니다 Effect는 React 밖으로 내보낸 시스템에 메시지만 보내면 되기 때문에 이런 방식은 Effect가 심플한 상태를 유지하도록 합니다.

위의 예시는 페이드인 로직이 자바스크립트로 작성되어야 하는 경우라고 가정합니다. 하지만 이런 특정 페이드인 애니메이션은 일반 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 {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

가끔 Hook이 필요하지 않을 수 있습니다!

요약

  • 커스텀 Hook을 사용하면 컴포넌트 간 로직을 공유할 수 있습니다.
  • 커스텀 Hook의 이름은 use 뒤에 대문자로 시작되어야 합니다.
  • 커스텀 Hook은 state 자체가 아닌 state 저장 로직만 공유합니다.
  • 하나의 Hook에서 다른 Hook으로 반응형 값을 전달할 수 있고, 값은 최신 상태로 유지됩니다.
  • 모든 Hook은 컴포넌트가 재렌더링될 때 마다 재실행됩니다.
  • 커스텀 Hook의 코드는 컴포넌트 코드처럼 순수해야 합니다.
  • 커스텀 Hook을 통해 받는 이벤트 핸들러는 Effect로 감싸야 합니다.
  • useMount같은 커스텀 Hook을 생성하면 안 됩니다. 용도를 명확히 하세요.
  • 코드의 경계를 선택하는 방법과 위치는 여러분이 결정할 수 있습니다.

챌린지 1 of 5:
useCounter Hook 추출하기

이 컴포넌트는 매초 증가하는 숫자를 보여주기 위해 state 변수와 Effect를 사용합니다. useCounter라는 커스텀 Hook으로 이 로직을 분리해 봅시다. 우리의 목표는 정확히 다음과 같이 동작하는 Counter를 만드는 것입니다.

export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}

useCounter.js 에 커스텀 Hook을 작성하고 Counter.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>;
}