RSS듀오랩스
React

React 커스텀 훅은 state를 공유할까?

작성자
듀오랩스 대표·8분 읽기

장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.

function useCart() {
  const [items, setItems] = useState([]);
  const add = (item) => setItems((prev) => [...prev, item]);
  return { items, add };
}

function CartIcon() {
  const { items } = useCart();
  return <span>장바구니 {items.length}</span>;
}

function ProductPage() {
  const { add } = useCart();
  return <button onClick={() => add(product)}>담기</button>;
}

「담기」를 눌러도 아이콘의 숫자는 0 그대로입니다. 같은 useCart 를 썼는데 두 컴포넌트가 서로 다른 장바구니를 보고 있습니다.

커스텀 훅은 state 를 공유하는 방법이라는 생각

여러 컴포넌트에서 같은 코드를 쓰고 싶을 때 커스텀 훅을 만듭니다. 같은 훅을 부르면 같은 것을 쓴다고 느끼기 쉽고, 특히 이름이 useCart 처럼 명사라면 「장바구니 하나를 여러 곳에서 꺼내 쓰는 창구」로 이해하게 됩니다.

Reusing Logic with Custom Hooks 문서는 이 오해를 소제목으로 바로잡습니다.

Custom Hooks let you share stateful logic, not state itself

공유되는 것은 state 를 다루는 로직이고, state 자체는 공유되지 않습니다.

훅을 부를 때마다 state 가 새로 생긴다

커스텀 훅은 이름이 use 로 시작하는 평범한 함수입니다. CartIconuseCart() 를 부르면 그 안의 useState([])CartIcon 의 훅 목록에 칸을 하나 만듭니다. ProductPageuseCart() 를 부르면 ProductPage 의 훅 목록에 또 하나를 만듭니다.

훅 규칙 글에서 React 가 state 를 컴포넌트마다 호출 순서대로 들고 있다고 했습니다. 커스텀 훅은 그 순서 목록에 들어가는 호출을 한데 묶어 이름을 붙인 것일 뿐입니다. 컴포넌트 두 개가 같은 훅을 부르면, 같은 코드가 두 컴포넌트의 목록에 각각 펼쳐집니다.

문서의 요약도 같습니다. 컴포넌트가 다시 렌더링되면 그 안의 훅 코드도 모두 다시 실행되고, 훅을 부르는 곳마다 state 는 완전히 독립적입니다.

같은 state 를 보려면 state 가 한 곳에 있어야 한다

장바구니를 정말 하나로 보려면 state 가 한 컴포넌트에 있고, 나머지가 그것을 받아야 합니다. 두 가지 방법이 있습니다.

가까운 공통 부모에 두고 props 로 내려 줍니다.

function Shop() {
  const cart = useCart();          // state 는 여기 하나
  return (
    <>
      <CartIcon items={cart.items} />
      <ProductPage onAdd={cart.add} />
    </>
  );
}

트리의 먼 곳까지 필요하다면 Context 로 전달하고, 커스텀 훅은 그 Context 를 읽는 입구로 씁니다.

const CartContext = createContext(null);

function CartProvider({ children }) {
  const cart = useCartState();     // state 를 만드는 훅은 Provider 에서 한 번만
  return <CartContext value={cart}>{children}</CartContext>;
}

function useCart() {
  return useContext(CartContext);  // 읽는 훅은 어디서 불러도 같은 값
}

이렇게 나누면 이름이 역할을 말해 줍니다. useCartState 는 호출할 때마다 새 장바구니를 만들고, useCart 는 이미 있는 장바구니를 읽습니다. 처음 코드의 문제는 두 역할이 한 이름에 섞여 있었던 것이라고 봅니다.

커스텀 훅으로 뺄 만한 것

그렇다면 커스텀 훅은 언제 쓸까요. 문서의 예는 네트워크 연결 상태를 추적하는 useOnlineStatus 입니다. 브라우저의 온라인, 오프라인 이벤트를 구독하고 해제하는 효과와 state 가 함께 필요합니다. 이 로직을 여러 컴포넌트가 쓰면 각자 구독하고 각자 state 를 갖지만, 브라우저라는 같은 외부 시스템을 보므로 값이 같게 유지됩니다.

문서는 특히 효과를 쓸 때 커스텀 훅으로 감쌀지 생각해 보라고 권합니다. Synchronizing with Effects 문서가 설명하듯 효과는 외부 시스템과의 동기화인데, 그 동기화에 useChatRoom(roomId) 같은 이름을 붙이면 컴포넌트는 「무엇을 하는지」만 보이고 「어떻게 하는지」는 훅 안으로 들어갑니다.

반대로 문서가 권하지 않는 것도 있습니다.

첫째, 작은 중복마다 훅을 만드는 것. useState 하나를 감싸는 useFormInput 같은 훅은 대개 필요 없다고 적습니다. 약간의 중복은 괜찮습니다.

둘째, 생명주기 이름의 훅. useMount 처럼 「마운트될 때 실행」을 감싸는 훅은 만들지 말고, 목적이 구체적인 훅을 만들라고 합니다. useMount(() => connect(roomId))roomId 가 바뀔 때 다시 연결해야 한다는 사실을 숨기고, 의존성 린트도 우회합니다. useChatRoom(roomId) 은 그 사실을 이름과 인자에 드러냅니다.

셋째, 훅을 쓰지 않는 함수에 use 를 붙이는 것. 이름이 use 로 시작하면 린터가 훅 규칙을 적용합니다. 안에서 훅을 부르지 않는 유틸 함수라면 평범한 이름이 맞습니다.

훅 사이로 값이 흐른다

커스텀 훅끼리 값을 주고받을 수도 있습니다.

function ShippingForm({ country }) {
  const cities = useCities(country);
  const [city, setCity] = useState(null);
  const areas = useAreas(city);
  // ...
}

country 가 바뀌면 컴포넌트가 다시 렌더링되고, useCities 가 새 country 로 다시 실행됩니다. 문서는 훅 사이에 넘기는 값이 항상 최신으로 유지된다고 설명합니다. 훅 역시 렌더링마다 다시 실행되는 함수이기 때문입니다. 그래서 커스텀 훅의 코드도 컴포넌트처럼 순수해야 한다고 문서는 덧붙입니다.

여기까지가 확실한 부분

커스텀 훅이 state 가 아니라 로직을 공유한다는 점, 훅 이름 규칙, 작은 중복과 useMount 같은 훅을 권하지 않는다는 점, 훅 사이에 넘긴 값이 최신으로 유지된다는 점은 react.dev 문서 기준입니다. useCartuseCartState 로 역할을 나누는 이름 짓기는 이해를 돕기 위한 제 판단이고, 문서가 정한 관례는 아닙니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.