[React] Reusing Logic with Custom Hooks

Chanhee Kang·2022년 11월 10일
0

Front-end

목록 보기
2/3

본 포스팅은 React Beta문서, Reusing Logic with Custom Hooks를 일부 번역하였습니다.

React에는 useState, useContext, useEffect와 같은 몇 가지 내장 Hook이 존재합니다. 데이터를 가져오거나, 사용자가 온라인 상태인지 확인 하거나, 채팅방에 연결하는 등의 좀 더 구체적인 목적을 위해 Hook이 있었으면 하는 경우가 있습니다. React에서 이러한 Hook을 찾지 못할 수도 있지만 애플리케이션의 필요에 따라 커스텀 Hook을 만들 수 있습니다.

Custom Hooks: Sharing logic between component

대부분의 application이 그렇듯이 네트워크에 크게 의존하는 앱을 개발한다고 상상해보겠습니다. App을 사용하는 동안 네트워크 연결이 실수로 끊어진 경우 사용자에게 경고를 해야할 경우에는 크게 두 가지가 컴포넌트 안에 필요한 것 같습니다.

  1. 네트워크가 온라인인지 여부를 추적하는 상태입니다.
  2. 전역 온라인 및 오프라인 이벤트를 구독하고 해당 상태를 업데이트하는 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 ? '✅ Oline' : '❌ Disconnected'}</h1>;
}

이제 다른 컴포넌트에서 동일한 로직을 사용해 보려고 합니다. 네트워크가 꺼져 있는 동안 비활성화되고 "저장" 대신 "다시 연결 중..."이 표시되는 저장 버튼을 구현하려고 합니다. 이를 구현하기 위해, 먼저 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>
  );
}

네트워크를 끄면 버튼의 모양이 변경되는지 확인하십시오. 코드 내 두개 컴포넌트는 잘 작동하지만 이들 간의 로직상 중복이 존재합니다. 시각적으로 다른 모양을 하고 있어도 둘 사이의 로직을 재사용하고 싶은 것 같습니다.

Extracting your own custom Hook from a component

useStateuseEffect와 유사하게 내장된 useOnlineStatus Hook이 있다고 가정해 봅시다. 그러면 이 두가지 컴포넌트를 모두 단순화하고 둘 사이의 중복을 제거할 수 있을겁니다. 비록 이러한 내장 Hook은 없지만 직접 작성할 수 있습니다. useOnlineStatus라는 함수를 선언하고 이전에 작성한 컴포넌트의 코드를 해당 함수로 복사합니다.

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

함수가 끝나면 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 />
    </>
  );
}
// useOnlineStatus.js
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;
}

이제 네트워크를 켜고 끄면 두 구성 요소가 모두 업데이트되는지 확인해봅시다. 이제 컴포넌트 안에 반복적인 로직이 많지 않습니다. 더 중요한 것은 내부의 코드가 수행하는 방법보다 수행하려는 작업을 설명한다는 것입니다. 커스텀 Hooks을 통해 로직을 추출할 때 외부 시스템이나 브라우저 API를 처리하는 방법에 대한 복잡한 세부 사항을 숨길 수도 있습니다.

Hook names always start with use

React 애플리케이션은 컴포넌트로 구축됩니다. 컴포넌트는 내장 또는 사용자 지정 여부에 관계없이 Hook에서 빌드됩니다. 다른 사람이 만든 사용자 정의 Hook을 자주 사용하지만 때로는 직접 작성할 수도 있으며, 다음 규칙을 따라야 합니다.

  1. React 구성 요소 이름은 StatusBar 및 SaveButton과 같이 대문자로 시작해야 합니다. 또한 React 구성 요소는 JSX와 같이 React가 표시하는 방법을 알고 있는 것을 반환해야 합니다.
  2. Hook의 이름은 useState나 useOnlineStatus와 같이 대문자가 뒤에 와야 합니다. Hook은 임의의 값을 반환할 수 있습니다.

해당 컨밴션을 사용하면 컴포넌트를 통해 state, effect, 혹은 기타 React 기능이 "숨길" 수 있는 위치를 알 수 있습니다. 예를 들어, 컴포넌트 내부에서 getColor() 함수 호출을 본다면 이름이 use로 시작하지 않기 때문에 내부에 React 상태를 포함할 수 없다는 것을 확신할 수 있습니다. 그러나 useOnlineStatus()와 같은 함수 호출에는 내부에 다른 Hooks에 대한 호출이 포함될 가능성이 존재함을 확인 할수 있습니다.

그럼 렌더링 중에 호출되는 모든 함수는 use로 시작해야 할까요? react beta 문서에 따르면 아니라고 합니다. Hooks를 호출하지 않는 함수는 Hooks일 필요가 없습니다. 함수가 Hook을 호출하지 않는 경우 use를 사용하지 말고, 접두사 없이 일반 함수로 작성하는것을 권장하고 있습니다. 예를 들어 아래의 useSorted는 Hooks를 호출하지 않으므로 대신 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) {
    // ✅ It's ok to call getSorted() conditionally because it's not a Hook
    displayedItems = getSorted(items);
  }
  // ...
}

함수 내부에 하나 이상의 Hook을 사용하는 경우 함수에 앞에 use를 써주어야 합니다.

// ✅ Good: A Hook that uses other Hooks
function useAuth() {
  return useContext(Auth);
}

앞에 use를 붙인다 하여 React에 의한 기술적 변화는 가져오지 않습니다. 원칙적으로 다른 Hook을 호출하지 않는 Hook을 만들 수도 있기는 하지만 소스코드가 혼란스러워 지고 제한적이므로 해당 패턴을 피하는 것이 가장 좋습니다. 그러나 드물게 도움이 되는 경우가 있습니다. 예를 들어, 함수가 지금은 Hooks를 사용하지 않지만 앞으로 Hook 호출을 추가할 계획입니다. 그런 다음 use 접두사로 이름을 지정하는 것이 좋습니다.

// ✅ Good: A Hook that will likely some other Hooks later
function useAuth() {
  // TODO: Replace with this line when authentication is implemented:
  // return useContext(Auth);
  return TEST_USER;
}

이렇게되면 state가 조건부로 호출할 수 없습니다. 이것은 실제로 내부에 Hook을 추가할 때 중요해집니다. 내부에 Hooks를 사용하지 않을 계획이라면 Hook으로 만들지 않는것을 권장합니다.

Custom Hooks let you share stateful logic, not state itself

앞의 예에서 네트워크를 켜고 끌 때 두 구성 요소가 함께 업데이트되었습니다. 그러나 단일 isOnline 상태 변수가 그들 사이에 공유된다고 생각하는 것은 잘못입니다. 이 코드를 보세요.

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

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

중복을 extract 하기 전과 같은 방식으로 작동합니다.

function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    // ...
  }, []);
  // ...
}

function SaveButton() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    // ...
  }, []);
  // ...
}

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

이것을 더 잘 설명하려면 다른 예가 필요합니다. 다음 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. 상태(firstName 및 lastName)가 있습니다.
  2. 변경 핸들러(handleFirstNameChange 및 handleLastNameChange)가 있습니다.

해당 입력에 대한 값과 onChange 속성을 지정하는 JSX가 있습니다. useFormInput 커스텀 훅으로 반복적인 로직을 추출할 수 있습니다:

// 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>
    </>
  );
}
//useFormInput.js
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라는 하나의 상태 변수만 선언합니다. 그러나 Form 구성 요소는 useFormInput을 두 번 호출합니다.

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

이것이 두 개의 개별 상태 변수를 선언하는 것처럼 작동하는 이유입니다. Custom Hooks를 사용하면 상태 저장 논리를 공유할 수 있지만 상태 자체는 공유할 수 없습니다. Hook에 대한 각 호출은 동일한 Hook에 대한 다른 모든 호출과 완전히 독립적입니다. 이것이 위의 두 샌드박스가 완전히 동일한 이유입니다. 원하는 경우 위로 스크롤하여 비교하십시오. 사용자 정의 Hook을 추출하기 전과 후의 동작은 동일합니다. 여러 구성 요소 간에 상태 자체를 공유해야 하는 경우 대신 들어 올리고 아래로 전달합니다.

Passing reactive values between Hooks

커스텀 Hooks 내부의 코드는 구성 요소를 다시 렌더링할 때마다 다시 실행됩니다. 이것이 컴포넌트와 마찬가지로 사용자 정의 Hook이 순수해야 하는 이유입니다. 사용자 정의 Hooks의 코드를 구성 요소 본문의 일부로 생각하면 됩니다.

커스텀 Hook은 컴포넌트와 함께 다시 렌더링되기 때문에 항상 최신 props와 state를 받습니다. 이것이 의미하는 바를 보려면 이 대화방 예를 고려하십시오

Custom Hooks help you migrate to better patterns

Effect는 "escape hatch"입니다. “step outside React" 할 필요가 있고 사용 사례에 더 나은 내장 솔루션이 없을 때 사용합니다. 시간이 지남에 따라 React 팀의 목표는 보다 구체적인 문제에 대한 보다 구체적인 솔루션을 제공하여 앱의 효과 수를 최소로 줄이는 것입니다. 사용자 정의 Hooks의 Wrapping Effects를 사용하면 이러한 솔루션을 사용할 수 있게 되면 코드를 더 쉽게 업그레이드할 수 있습니다. 이 예를 다시 살펴보겠습니다.

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를 사용하여 확인할 수 있지만 서버에서 React 앱을 실행하여 초기 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, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

Custom Hooks를 사용하면 구성 요소 간에 논리를 공유할 수 있습니다. 또한, Custom Hooks의 이름은 use로 시작하고 그 뒤에 대문자가 와야 한다고 하며 state 자체가 아닌 상태 저장 논리만 공유합니다.

0개의 댓글