ReactNextCentral

사용자 정의 훅: 로직 재사용

Published on
이 문서에서는 커스텀 훅을 작성하고 활용하여 리액트 컴포넌트 간에 로직을 공유하는 방법을 소개합니다.
Table of Contents

리액트에는 useState, useContext, useEffect와 같은 여러 내장 훅이 있습니다. 때로는 더 구체적인 목적을 가진 특정한 훅이 있다면 좋을 것 같습니다. 예를 들어 데이터를 가져오거나 사용자의 온라인 상태를 추적하거나 채팅방에 연결하는 등의 경우입니다. 이러한 훅들을 리액트에서 찾을 수 없을 수도 있지만, 애플리케이션의 필요에 맞게 직접 커스텀 훅을 만들 수 있습니다.

배우게 될 내용:

  • 커스텀 훅이란 무엇이며 어떻게 작성하는지
  • 컴포넌트 간에 로직을 재사용하는 방법
  • 커스텀 훅의 이름 지정 및 구조화 방법
  • 커스텀 훅을 추출하는 시기와 이유

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

가장 많은 앱들이 그렇듯이 네트워크를 강하게 의존하는 앱을 개발하고 있다고 상상해보세요. 사용자가 앱을 사용하는 동안 실수로 네트워크 연결이 끊어졌다면 사용자에게 경고를 표시하고 싶을 것입니다. 이를 위해 컴포넌트에 두 가지가 필요할 것 같습니다.

  1. 네트워크가 온라인 상태인지를 추적하는 상태 값.
  2. 전역 online 및 offline 이벤트에 구독하고 이 상태 값을 업데이트하는 Effect.

이렇게 함으로써 컴포넌트가 네트워크 상태와 동기화됩니다. 다음과 같이 시작할 수 있습니다.

Edit youthful-carson-nsny83

네트워크를 켜고 끄면 이 StatusBar가 작업에 맞춰 업데이트되는 것을 확인해보세요.

이제 동일한 로직을 다른 컴포넌트에서도 사용하고 싶다고 가정해보세요. 네트워크가 꺼진 동안 "저장" 대신 "재연결 중..."을 표시하고 버튼을 비활성화하는 "저장" 버튼을 구현하려고 합니다.

먼저, isOnline 상태와 Effect를 SaveButton에 복사하여 붙여넣을 수 있습니다.

Edit solitary-fast-b99g0g

네트워크를 끄면 버튼의 모습이 변경되는지 확인하세요.

이 두 개의 컴포넌트는 정상적으로 작동하지만, 로직 중복은 좋지 않습니다. 시각적인 모양은 다르지만 로직을 재사용하고 싶습니다.

컴포넌트에서 커스텀 훅 추출하기

잠시 useStateuseEffect와 유사한 내장 훅인 useOnlineStatus 가 있다고 상상해보세요. 그러면 이 두 개의 컴포넌트를 간단하게 만들고 중복을 제거할 수 있을 것입니다.

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

비록 이러한 내장 훅은 존재하지 않지만 직접 만들 수 있습니다. 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을 반환합니다. 이를 통해 컴포넌트에서 해당 값을 읽을 수 있습니다.

Edit peaceful-leftpad-9x38on

네트워크를 켜고 끄면 두 개의 컴포넌트가 업데이트되는지 확인하세요.

이제 컴포넌트에는 반복되는 로직이 더 이상 없습니다. 더 중요한 것은 컴포넌트 내부의 코드가 어떻게 처리되는지(브라우저 이벤트에 구독하는 방식)가 아닌, 무엇을 수행하는지(온라인 상태 사용!)를 설명하고 있다는 점입니다.

커스텀 훅으로 로직을 추출하면 외부 시스템이나 브라우저 API와 같은 복잡한 세부 사항을 숨길 수 있습니다. 컴포넌트의 코드는 구현이 아니라 의도를 나타냅니다.

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

리액트 애플리케이션은 컴포넌트로 구성됩니다. 컴포넌트는 내장 훅 또는 커스텀 훅을 사용하여 구성됩니다. 다른 사람이 작성한 커스텀 훅을 자주 사용할 수도 있지만 때로는 직접 작성해야 할 수도 있습니다!

다음 네이밍 규칙을 따라야 합니다.

  1. 리액트 컴포넌트의 이름은 대문자로 시작해야 합니다. StatusBarSaveButton과 같이 대문자로 시작해야 합니다. 리액트 컴포넌트는 무언가를 반환해야 하며, 리액트가 표시할 수 있는 JSX 조각과 같은 것을 반환해야 합니다.
  2. 훅의 이름은 use로 시작하고 대문자로 이어져야 합니다. 내장 훅인 useState(내장 훅) 또는 useOnlineStatus(커스텀 훅)와 같이 use로 시작하고 대문자로 시작해야 합니다. 훅은 임의의 값을 반환할 수 있습니다.

이 규칙은 언제나 컴포넌트를 확인하여 상태, 효과 및 기타 리액트 기능이 어디에 "숨어" 있는지 알 수 있도록 보장합니다. 예를 들어 getColor() 함수 호출을 컴포넌트 내부에서 볼 때, 이름이 use로 시작하지 않기 때문에 리액트 상태를 포함하지 않을 것이라고 확신할 수 있습니다. 그러나 useOnlineStatus()와 같은 함수 호출은 내부에 다른 훅 호출이 포함될 가능성이 높습니다!

참고
리액트용 린터가 구성되어 있으면, 이 네이밍 규칙을 강제합니다. 위의 샌드박스로 돌아가서 useOnlineStatusgetOnlineStatus로 변경해 보세요. 린터가 더 이상 useState 또는 useEffect 호출을 허용하지 않는 것을 알 수 있습니다. 훅과 컴포넌트만 다른 훅을 호출할 수 있습니다!

자세히 알아보기: 렌더링 중에 호출되는 모든 함수는 use 접두사로 시작해야 할까요?

아닙니다. 훅을 호출하지 않는 함수는 use 접두사가 필요하지 않습니다.

만약 함수가 훅을 호출하지 않는다면, use 접두사 대신에 일반 함수로 작성하세요. 예를 들어, 아래의 useSorted는 훅을 호출하지 않기 때문에 getSorted로 호출해야 합니다.

// 🔴 피하세요: 훅을 사용하지 않는 훅
function useSorted(items) {
  return items.slice().sort();
}

// ✅ 좋은 예: 훅을 사용하지 않는 일반 함수
function getSorted(items) {
  return items.slice().sort();
}

이렇게 하면 일반 함수로 작성된 코드는 조건문을 포함하여 어디에서든 호출할 수 있습니다.

function List({ items, shouldSort }) {
  let displayedItems = items;
  if (shouldSort) {
    // ✅ 훅이 아니기 때문에 조건부로 getSorted()를 호출해도 괜찮습니다.
    displayedItems = getSorted(items);
  }
  // ...
}

만약 함수 내부에서 적어도 하나의 훅을 사용한다면, use 접두사를 붙여서 함수를 훅으로 만들어야 합니다.

// ✅ 좋은 예: 다른 훅을 사용하는 훅
function useAuth() {
  return useContext(Auth);
}

리액트에서 이를 강제하는 것은 아닙니다. 원칙적으로, 훅을 호출하지 않는 훅을 만들 수도 있습니다. 그러나 이는 종종 혼란스럽고 제한적이므로 이러한 패턴은 피하는 것이 좋습니다. 그러나 훗날 해당 함수에 훅을 추가할 계획이 없다면 훅으로 만들지 않는 것이 좋습니다.

// ✅ 좋은 예: 향후에 다른 훅을 사용할 수 있는 훅
function useAuth() {
  // TODO: 인증이 구현될 때 아래의 라인으로 대체하세요:
  // return useContext(Auth);
  return TEST_USER;
}

그런 다음 컴포넌트에서 조건부로 호출할 수 없습니다. 이것은 실제로 훅 호출을 추가할 때 중요해집니다. 만약 이 함수에서 훅을 사용할 계획이 없다면(지금 또는 향후), 훅으로 만들지 마세요.

커스텀 훅은 상태 자체를 공유하는 것이 아니라 상태 로직을 공유합니다.

이전 예제에서 네트워크를 켜고 끌 때 두 컴포넌트가 함께 업데이트되었습니다. 그러나 단일 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(() => {
    // ...
  }, []);
  // ...
}

이들은 완전히 독립적인 상태 변수와 효과입니다! 두 변수는 완전히 독립적인 상태이며, 동일한 값을 가진다는 것은 외부 값(네트워크가 켜져 있는지 여부)을 동기화했기 때문입니다.

더 명확하게 설명하기 위해 다른 예제가 필요합니다. 다음 Form 컴포넌트를 고려해 보세요:

Edit goofy-franklin-66tyej

각 폼 필드에 중복된 로직이 있습니다.

  1. 상태 값(firstName과 lastName)이 있습니다.
  2. 변경 핸들러(handleFirstNameChange와 handleLastNameChange)가 있습니다.
  3. input의 value와 onChange 속성을 지정하는 JSX 코드가 있습니다.

중복된 로직을 useFormInput 커스텀 훅으로 추출할 수 있습니다.

Edit sad-allen-bed9z6

이 훅은 value라는 하나의 상태 변수만 선언합니다.

하지만 Form 컴포넌트에서 useFormInput을 두 번 호출합니다.

function Form() {
  const firstNameProps = useFormInput('Mary');
  const lastNameProps = useFormInput('Poppins');
  // ...

이렇게 하면 두 개의 별개의 상태 변수가 선언된 것처럼 작동합니다!

커스텀 훅은 상태 자체를 공유하는 것이 아니라 상태 로직을 공유합니다. 동일한 훅을 여러 번 호출하는 각 호출은 완전히 독립적입니다. 이것이 위의 두 가지 샌드박스가 완전히 동등한 이유입니다. 원한다면 위로 스크롤하여 비교해 보세요. 커스텀 훅을 추출하기 전과 후의 동작은 동일합니다.

여러 컴포넌트 간에 상태 자체를 공유해야 하는 경우, 상태를 끌어올리고 전달하세요.

Hooks 간에 반응적인 값 전달하기

커스텀 훅 내부의 코드는 컴포넌트의 모든 재렌더링마다 다시 실행됩니다. 이것이 컴포넌트와 마찬가지로 커스텀 훅 순수(pure)해야 하는 이유입니다. 커스텀 훅의 코드를 컴포넌트의 일부로 생각해 보세요!

커스텀 훅은 컴포넌트와 함께 재렌더링되므로 항상 최신의 속성(props)과 상태(state)를 받습니다. 이를 이해하기 위해 다음의 채팅방 예제를 살펴보세요. serverUrl이나 roomId을 변경해 보세요:

Edit happy-ace-x43dm1

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 컴포넌트에서는 내부 동작을 걱정하지 않고 커스텀 훅을 호출할 수 있습니다.

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) 변경에 반응하는 것에 주목하세요. server URL이나 선택한 방을 편집해 보세요:

Edit priceless-yonath-2bjiw9

함수 호출 결과를 다른 훅의 입력으로 전달하는 방식으로 하나의 훅의 반환 값을 가져와:

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https

://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });
  // ...

다른 훅에 전달하는 것을 볼 수 있습니다.

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  useChatRoom({
    roomId: roomId,
    serverUrl: serverUrl
  });
  // ...

ChatRoom 컴포넌트가 재렌더링될 때마다 최신의 roomId와 serverUrl이 커스텀 훅에 전달됩니다. 이로 인해 이펙트가 재렌더링 후 값이 다른 경우에만 채팅이 다시 연결됩니다. (오디오나 비디오 처리 소프트웨어와 작업한 적이 있다면 이렇게 훅을 연결하는 것이 시각적이거나 오디오 효과를 연결하는 것과 유사함을 알 수 있을 것입니다. 마치 useState의 출력이 useChatRoom의 입력으로 "피드"되는 것과 같은 느낌입니다.)

커스텀 훅에 이벤트 핸들러 전달하기

이 섹션은 아직 안정 버전의 리액트에서 릴리즈되지 않은 실험적인 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);
    }
  });
  // ...

이를 작동시키기 위해 커스텀 훅을 변경하여 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
}

이렇게 하면 작동하지만, 커스텀 훅이 이벤트 핸들러를 받을 때 추가 의존성을 추가하는 것은 이상적이지 않습니다. 이를 위해 이벤트 핸들러를 의존성에서 제거하기 위해 이벤트 핸들러를 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 컴포넌트가 재렌더링될 때마다 채팅이 다시 연결되지 않습니다. 이를 사용하여 이벤트 핸들러를 커스텀 훅에 전달하는 작동하는 데모를 확인할 수 있습니다.

Edit nervous-curie-3bzejk

useChatRoom이 작동하는 방식을 더 이상 알 필요가 없으므로 사용할 수 있습니다. 다른 컴포넌트에 추가하고 다른 옵션을 전달해도 같은 방식으로 작동할 것입니다. 이것이 커스텀 훅의 강력한 기능입니다.

커스텀 훅을 사용해야 하는 시기

매번 반복되는 작은 코드 조각마다 커스텀 훅을 추출할 필요는 없습니다. 일부 중복은 괜찮습니다. 예를 들어, 앞서 언급한 것처럼 단일 useState 호출을 래핑하기 위해 useFormInput 커스텀 훅을 추출하는 것은 아마도 필요하지 않을 것입니다.

하지만 Effect를 작성할 때마다 해당 Effect를 커스텀 훅으로 래핑하는 것이 더 명확할지 고려해보세요. 자주 Effect가 필요하지 않을테니까요. 따라서 Effect를 작성하는 경우, 이는 리액트 외부의 일부 시스템과 동기화하거나 리액트에 내장된 API가 없는 작업을 수행해야 함을 의미합니다. 커스텀 훅으로 래핑하면 의도와 데이터 흐름을 정확하게 전달할 수 있습니다.

예를 들어, 선택한 도시의 목록을 표시하는 도시 드롭다운과 해당 도시에서의 지역 목록을 표시하는 두 번째 드롭다운을 표시하는 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]);

  // ...

이 코드는 중복이 꽤 많지만, 이러한 Effects를 서로 분리하는 것이 올바릅니다. 이들은 두 가지 다른 요소를 동기화하므로 하나의 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 컴포넌트에서 두 개의 Effects를 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);
  // ...

커스텀 훅을 추출함으로써 데이터 흐름이 명확해집니다. URL을 입력하면 데이터가 반환됩니다. useData 내부에 Effect를 "숨기면서" ShippingForm 컴포넌트에서 불필요한 종속성이 추가되는 것을 방지합니다. 시간이 지남에 따라 앱의 대부분의 Effects는 커스텀 훅에 들어갈 것입니다.

자세히 알아보기: 커스텀 훅은 구체적인 고수준 사용 사례에 집중하세요.*

먼저 커스텀 훅의 이름을 선택하세요. 명확한 이름을 선택하는 데 어려움이 있다면, 해당 효과가 나머지 컴포넌트 로직에 너무 의존적이고 아직 추출할 준비가 되지 않았을 수도 있습니다.

이상적으로 커스텀 훅의 이름은 다음과 같아야 합니다. 심지어 코드를 자주 작성하지 않는 사람도 커스텀 훅이 무엇을 하는지, 어떤 입력을 받고 어떤 값을 반환하는지에 대해 추측할 수 있어야 합니다.

  • ✅ useData(url)
  • ✅ useImpressionLog(eventName, extraData)
  • ✅ useChatRoom(options)

외부 시스템과 동기화하는 경우, 커스텀 훅의 이름은 해당 시스템에 익숙한 사람에게 명확하게 이해될 수 있는 기술 용어를 사용해도 좋습니다.

  • ✅ useMediaQuery(query)
  • ✅ useSocket(url)
  • ✅ useIntersectionObserver(ref, options)

구체적인 고수준 사용 사례에 집중하는 커스텀 훅을 유지하세요. useEffect API 자체의 대체 및 편의 래퍼 역할을 하는 커스텀 "라이프사이클" 훅을 만들고 사용하는 것은 피하세요:

  • 🔴 useMount(fn)
  • 🔴 useEffectOnce(fn)
  • 🔴 useUpdateEffect(fn)

예를 들어, useMount 훅은 코드가 "마운트"될 때만 실행되도록 하는 것을 목표로 합니다.

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // 🔴 피하세요: 커스텀 "라이프사이클" 훅 사용
  useMount(() => {
    const connection = createConnection({ roomId, serverUrl });
    connection.connect();

    post('/analytics/event', { eventName: 'visit_chat' });
  });
  // ...
}

// 🔴 피하세요: 커스텀 "라이프사이클" 훅 생성
function useMount(fn) {
  useEffect(() => {
    fn();
  }, []); // 🔴 React Hook useEffect has a missing dependency: 'fn'
}

useMount와 같은 커스텀 "라이프사이클" 훅은 리액트 패러다임과 잘 어울리지 않습니다. 예를 들어, 이 코드 예제에는 오류가 있습니다 (roomId나 serverUrl 변경에 "반응"하지 않음), 그러나 린터는 라이프사이클 훅에 대해 경고하지 않습니다. 왜냐하면 린터는 직접적인 useEffect 호출만 확인하고 커스텀 훅에 대해 알지 못하기 때문입니다.

Effect를 작성하는 경우, 먼저 리액트 API를 직접 사용하세요:

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // ✅ 좋은 예: 목적에 따라 분리된 두 개의 기본적인 Effects
  useEffect(() => {
    const connection = createConnection({ serverUrl, roomId });
    connection.connect();
    return () => connection.disconnect();
  }, [serverUrl, roomId]);

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_chat', roomId });
  }, [roomId]);

  // ...
}

그런 다음 다른 고수준 사용 사례에 대해 커스텀 훅을 (하지만 해도 됩니다) 추출할 수 있습니다.

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

  // ✅ 훌륭한 예: 목적에 따라 명명된 커스텀 훅
  useChatRoom({ serverUrl, roomId });
  useImpressionLog('visit_chat', { roomId });
  // ...
}

좋은 커스텀 훅은 호출 코드를 더 선언적으로 만들어줍니다. 예를 들어, useChatRoom(options)는 채팅 룸에만 연결할 수 있으며, useImpressionLog(eventName, extraData)는 분석에 대한 인상 로그를 전송할 수 있습니다. 커스텀 훅 API가 사용 사례를 제약하고 매우 추상적이지 않은 경우, 오랜 기간이 지나면 문제를 해결하기보다는 더 많은 문제를 야기할 가능성이 있습니다.

커스텀 훅은 더 나은 패턴으로의 전환을 도와줍니다.

Effect는 탈출구(escape hatch)입니다. 이는 리액트의 외부로 벗어나야 할 때와 사용 사례에 더 나은 내장된 해결책이 없을 때 사용합니다. 시간이 지남에 따라 리액트 팀의 목표는 더 구체적인 문제에 대한 더 구체적인 해결책을 제공하여 앱에서 Effect의 수를 최소화하는 것입니다. 커스텀 훅에 Effect를 감싸면 이러한 해결책이 사용 가능해질 때 코드를 업그레이드하기 쉬워집니다.

다음 예제로 돌아가 봅시다.

Edit peaceful-leftpad-9x38on

위의 예제에서 useOnlineStatus는 useStateuseEffect를 사용하여 구현되었습니다. 그러나 이것은 최상의 해결책은 아닙니다. 고려하지 않은 여러 가지 예외 상황이 있습니다. 예를 들어, 컴포넌트가 마운트될 때 isOnline이 이미 true라고 가정하지만, 네트워크가 이미 오프라인 상태인 경우 이 가정이 잘못될 수 있습니다. 이를 확인하기 위해 브라우저의 navigator.onLine API를 사용할 수 있지만, 이를 직접 사용하는 것은 초기 HTML을 생성하는 서버에서 작동하지 않습니다. 요약하면, 이 코드를 개선할 수 있습니다.

다행히도 리액트 18에는 이러한 모든 문제를 해결해주는 useSyncExternalStore라는 전용 API가 포함되어 있습니다. 이제 이 새로운 API를 활용하도록 다시 작성한 useOnlineStatus 훅은 다음과 같습니다.

Edit serene-joji-x3ll1x

마이그레이션을 위해 어떠한 컴포넌트도 변경할 필요가 없었다는 점에 주목하세요:

function StatusBar() {
  const isOnline = useOnlineStatus();
  // ...
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  // ...
}

이는 Effect를 커스텀 훅으로 감싸는 것이 종종 유익한 이유 중 하나입니다.

  1. Effect와의 데이터 흐름이 명확해집니다.
  2. 컴포넌트가 정확한 Effect 구현에 집중하기보다 의도에 집중할 수 있습니다.
  3. 리액트가 새로운 기능을 추가하면 컴포넌트를 수정하지 않고 해당 Effect를 제거할 수 있습니다.

디자인 시스템과 유사하게, 앱의 컴포넌트에서 공통적인 관용구를 커스텀 훅으로 추출하는 것이 도움이 될 수 있습니다. 이렇게 하면 컴포넌트 코드가 의도에 집중되고 원시적인 Effect를 자주 작성하지 않을 수 있습니다. 리액트 커뮤니티에서는 많은 훌륭한 커스텀 훅이 유지되고 있습니다.

자세히 알아보기: 리액트에서 데이터 가져오기에 대한 내장된 솔루션을 제공할까요?

세부 사항은 아직 작업 중이지만, 미래에는 데이터 가져오기를 다음과 같이 작성할 것으로 예상됩니다.

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와 같은 커스텀 훅을 사용하면 추후 권장되는 접근 방식으로 마이그레이션할 때 수행해야 하는 변경 사항이 적어집니다. 그러나 이전 접근 방식도 여전히 잘 작동하므로 원시적인 Effects를 작성하는 데 만족한다면 계속해서 사용할 수 있습니다.

다양한 방법이 존재합니다

예를 들어, 브라우저의 requestAnimationFrame API를 사용하여 페이드인 애니메이션을 처음부터 구현하고자 한다고 가정해봅시다. 애니메이션 루프를 설정하는 Effect를 시작으로, 애니메이션의 각 프레임에서 DOM 노드의 불투명도를 변경하여 1이 될 때까지 애니메이션을 진행할 수 있을 것입니다. 코드는 다음과 같이 시작할 수 있습니다.

Edit optimistic-ives-nlqetb

컴포넌트를 더 가독성 있게 만들기 위해 이 로직을 useFadeIn 커스텀 훅으로 추출할 수도 있습니다.

Edit sad-wozniak-iwzkkv

useFadeIn 코드를 그대로 유지할 수도 있지만, 더 리팩토링할 수도 있습니다. 예를 들어, 애니메이션 루프를 설정하는 로직을 useFadeIn에서 분리하여 커스텀 useAnimationLoop 훅으로 추출할 수도 있습니다.

Edit condescending-merkle-w6nwnb

그러나 이 작업을 할 필요는 없습니다. 일반 함수와 마찬가지로, 다른 코드 부분 사이의 경계를 어디에 그을지는 궁극적으로 여러분이 결정합니다. 매우 다른 접근 방식을 취할 수도 있습니다. 예를 들어, 로직을 Effect 안에 유지하는 대신 JavaScript 클래스 내부로 명령형 로직을 이동시킬 수도 있습니다.

Edit blazing-water-k3zome

Effects를 사용하면 리액트를 외부 시스템에 연결할 수 있습니다. Effects 간에 더 많은 조정(예: 여러 애니메이션을 연결하는 경우)이 필요할수록, 위의 샌드박스처럼 로직을 완전히 Effects와 Hooks에서 분리하는 것이 더 합리적입니다. 그러면 추출한 코드는 리액트의 외부 시스템이 됩니다. 이렇게 하면 Effects는 단순해지며, 단지 외부로 메시지를 전송하기만 하면 됩니다.

위의 예시들은 페이드인 로직을 JavaScript로 작성해야 한다고 가정합니다. 그러나 이 특정 페이드인 애니메이션은 일반적인 CSS 애니메이션을 사용하여 구현하는 것이 더 간단하고 효율적입니다.

Edit epic-leftpad-krfjws

때로는 훅을 사용할 필요조차 없을 수도 있습니다!

요약

  • 커스텀 훅을 사용하면 컴포넌트 간에 로직을 공유할 수 있습니다.
  • 커스텀 훅은 반드시 대문자로 시작하는 use로 시작해야 합니다.
  • 커스텀 훅은 상태 자체를 공유하는 것이 아니라 상태 로직만을 공유합니다.
  • 한 훅에서 다른 훅으로 반응적인 값을 전달할 수 있으며, 이 값들은 항상 최신 상태를 유지합니다.
  • 모든 훅은 컴포넌트가 재렌더링될 때마다 다시 실행됩니다.
  • 커스텀 훅의 코드는 컴포넌트의 코드와 마찬가지로 순수해야 합니다.
  • 커스텀 훅에서 전달받은 이벤트 핸들러는 Effect 이벤트로 래핑합니다.
  • useMount와 같은 커스텀 훅을 생성하지 마세요. 목적을 구체적으로 유지하세요.
  • 코드의 경계를 선택하는 방법과 위치는 여러분에게 달려있습니다.