리액트 레퍼런스 Hooks - useCallback

기운찬곰·2023년 10월 10일
post-thumbnail

원문 : https://react.dev/reference/react/useCallback

useCallback

Reference

useCallback은 리렌더링 사이에 함수 정의를 캐시할 수 있는 React Hook입니다.

const cachedFn = useCallback(fn, dependencies)

리렌더링 사이에 함수 정의를 캐시하려면 컴포넌트의 최상위 수준에서 useCallback을 호출하세요.

import { useCallback } from 'react';

export default function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

Parameters

  • fn: 캐시하려는 함수 값입니다. 모든 인수를 취하고 값을 반환할 수 있습니다. React는 초기 렌더링 중에 함수를 다시 반환합니다(호출하지 않음!). 다음 렌더링에서 React는 마지막 렌더링 이후 종속성이 변경되지 않은 경우 동일한 함수를 다시 제공합니다. 그렇지 않으면 현재 렌더링 중에 전달한 함수를 제공하고 나중에 다시 사용할 수 있도록 저장합니다. React는 함수를 호출하지 않습니다. 함수가 사용자에게 반환되므로 여러분이 호출 시기와 호출 여부를 결정할 수 있습니다.
  • dependencies : fn 코드 내부에서 참조되는 모든 반응 값의 목록입니다. 반응형 값에는 props, state 및 컴포넌트 본체 내부에 직접 선언된 모든 변수와 함수가 포함됩니다. Linter가 React용으로 구성된 경우 모든 반응 값이 종속성으로 올바르게 지정되었는지 확인합니다. 종속성 목록은 항목 수가 일정해야 하며 [dep1, dep2, dep3]처럼 인라인으로 작성되어야 합니다. React는 Object.is 비교 알고리즘을 사용하여 각 종속성을 이전 값과 비교합니다.

Returns

초기 렌더링 시 useCallback은 전달한 fn 함수를 반환합니다.

후속 렌더링 중에는 마지막 렌더링에서 이미 저장된 fn 함수를 반환하거나(종속성이 변경되지 않은 경우), 렌더링 중에 전달한 fn 함수를 반환합니다.

Caveats

  • useCallback은 Hook이므로 컴포넌트의 최상위 수준이나 자체 Hook에서만 호출할 수 있습니다. 루프나 조건 내에서는 호출할 수 없습니다. 필요한 경우 새 컴포넌트를 추출하고 상태를 해당 컴포넌트로 옮깁니다.
  • React는 특별한 이유가 없는 한 캐시된 함수를 버리지 않습니다. 예를 들어, 개발 중에 React는 컴포넌트의 파일을 편집할 때 캐시를 버립니다. 개발과 프로덕션 모두에서 React는 초기 마운트 중에 컴포넌트가 일시 중지되면 캐시를 버립니다. 미래에 React는 캐시를 버리는 이점을 활용하는 더 많은 기능을 추가할 수 있습니다. 예를 들어 React가 향후에 가상화된 목록에 대한 기본 지원을 추가한다면 가상화된 테이블 뷰포트 밖으로 스크롤되는 항목에 대한 캐시를 버리는 것이 합리적일 것입니다. 성능 최적화로 useCallback을 사용하는 경우 이는 예상과 일치해야 합니다. 그렇지 않으면 상태 변수나 ref가 더 적절할 수 있습니다.

Usage

Skipping re-rendering of components

렌더링 성능을 최적화할 때 하위 컴포넌트에 전달하는 함수를 캐시해야 하는 경우가 있습니다. 먼저 이 작업을 수행하는 방법에 대한 구문을 살펴본 다음 어떤 경우에 유용한지 살펴보겠습니다.

컴포넌트를 리렌더링하는 사이에 함수를 캐시하려면 해당 정의를 useCallback Hook에 래핑하세요.

import { useCallback } from 'react';

function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);
  // ...

Callback을 사용하려면 다음 두 가지를 전달해야 합니다.

  1. 리렌더링할 때마다 캐시하려는 함수 정의입니다.
  2. 함수 내에서 사용되는 컴포넌트 내의 모든 값을 포함하는 종속성 목록입니다.

초기 렌더링 시 useCallback에서 반환되는 함수는 전달한 함수가 됩니다.

다음 렌더링에서 React는 이전 렌더링 중에 전달한 종속성과 종속성을 비교합니다. 종속성이 변경되지 않은 경우(Object.is와 비교하여) useCallback은 이전과 동일한 함수를 반환합니다. 그렇지 않으면 useCallback은 이 렌더링에 전달한 함수를 반환합니다. (즉, 새로운 함수를 반환하다고 보면 된다)

즉, useCallback종속성이 변경될 때까지 리렌더링 간에 함수를 캐시합니다.

이것이 언제 유용한지 알아보기 위해 예제를 살펴보겠습니다.

ProductPage에서 ShippingForm 컴포넌트로 handlerSubmit 함수를 전달한다고 가정해 보겠습니다.

function ProductPage({ productId, referrer, theme }) {
  // ...
  return (
    <div className={theme}>
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );

theme prop을 토글하면 앱이 잠시 정지되는 것을 보셨겠지만, JSX에서 <ShippingForm />을 제거하면 속도가 빨라지는 느낌이 듭니다. 이는 ShippingForm 컴포넌트를 최적화해 볼 가치가 있음을 알려줍니다.

기본적으로 컴포넌트가 다시 렌더링되면 React는 모든 하위 항목을 재귀적으로 다시 렌더링합니다. 이것이 바로 ProductPage가 다른 테마로 다시 렌더링될 때 ShippingForm 컴포넌트도 다시 렌더링되는 이유입니다. 이는 리렌더링하는 데 많은 계산이 필요하지 않은 컴포넌트에 적합합니다. 그러나 리렌더링이 느린 것을 확인한 경우 props가 마지막 렌더링과 동일할 때 메모로 래핑하여 ShippingForm에 리렌더링을 건너뛰도록 지시할 수 있습니다.

import { memo } from 'react';

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  // ...
});

이 변경으로 ShippingForm은 모든 props가 마지막 렌더링과 동일하면 리렌더링을 건너뜁니다. 함수 캐싱이 중요해지는 순간입니다! useCallback 없이 handlerSubmit을 정의했다고 가정해 보겠습니다.

function ProductPage({ productId, referrer, theme }) {
  // Every time the theme changes, this will be a different function...
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }
  
  return (
    <div className={theme}>
      {/* ... so ShippingForm's props will never be the same, and it will re-render every time */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

JavaScript에서 함수 () {} 또는 () => {}는 항상 다른 함수를 생성합니다. 이는 {} 객체 리터럴이 항상 새 객체를 생성하는 방식과 유사합니다. 일반적으로 이는 문제가 되지 않지만 이는 ShippingForm props가 결코 동일하지 않으며 메모 최적화가 작동하지 않음을 의미합니다. useCallback이 유용한 곳은 다음과 같습니다.

function ProductPage({ productId, referrer, theme }) {
  // Tell React to cache your function between re-renders...
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]); // ...so as long as these dependencies don't change...

  return (
    <div className={theme}>
      {/* ...ShippingForm will receive the same props and can skip re-rendering */}
      <ShippingForm onSubmit={handleSubmit} />
    </div>
  );
}

useCallback에서 handlerSubmit을 래핑하면 리렌더링 간에(종속성이 변경될 때까지) 동일한 함수가 되도록 보장할 수 있습니다. 특별한 이유가 없는 한 useCallback에서 함수를 래핑할 필요는 없습니다. 이 예에서는 메모로 래핑된 컴포넌트에 전달하여 리렌더링을 건너뛸 수 있기 때문입니다.

Note. 성능 최적화를 위해 useCallback을 사용해야 합니다. 코드가 작동하지 않으면 근본적인 문제를 찾아 먼저 수정하세요. 그런 다음 useCallback을 다시 추가할 수 있습니다. (흠.. 그니까 성능 최적화가 필요한 경우에만 사용하라는 거 같네요. )

useCallback과 함께 useMemo를 자주 볼 수 있습니다. 둘 다 하위 컴포넌트를 최적화하려고 할 때 유용합니다. 이를 통해 전달 중인 내용을 메모할 수 있습니다(즉, 캐시할 수 있습니다).

import { useMemo, useCallback } from 'react';

function ProductPage({ productId, referrer }) {
  const product = useData('/product/' + productId);

  const requirements = useMemo(() => { // Calls your function and caches its result
    return computeRequirements(product);
  }, [product]);

  const handleSubmit = useCallback((orderDetails) => { // Caches your function itself
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    <div className={theme}>
      <ShippingForm requirements={requirements} onSubmit={handleSubmit} />
    </div>
  );
}

차이점은 캐시를 허용하는 것입니다.

  • useMemo는 함수 호출 결과를 캐시합니다. 이 예에서는 product이 변경되지 않는 한 변경되지 않도록 computeRequirements(product) 호출 결과를 캐시합니다. 이를 통해 ShippingForm을 불필요하게 다시 렌더링하지 않고도 요구 사항 개체를 전달할 수 있습니다. 필요한 경우 React는 렌더링 중에 전달한 함수를 호출하여 결과를 계산합니다.
  • useCallback은 함수 자체를 캐시합니다. useMemo와 달리 사용자가 제공하는 함수를 호출하지 않습니다. 대신, productId 또는 referrer가 변경되지 않는 한 handlerSubmit 자체가 변경되지 않도록 제공한 함수를 캐시합니다. 이렇게 하면 불필요하게 ShippingForm을 다시 렌더링하지 않고도 handlerSubmit 함수를 전달할 수 있습니다. 사용자가 form을 submits할 때까지 코드가 실행되지 않습니다.

useMemo에 이미 익숙하다면 useCallback을 다음과 같이 생각하는 것이 도움이 될 수 있습니다.

// Simplified implementation (inside React)
function useCallback(fn, dependencies) {
  return useMemo(() => fn, dependencies);
}

(DEEP DIVE.) Should you add useCallback everywhere?

앱이 이 사이트와 같고 대부분의 상호 작용이 대략적(예: 페이지 또는 전체 섹션 교체)인 경우 일반적으로 메모가 필요하지 않습니다. 반면에 앱이 그림 편집기에 더 가깝고 대부분의 상호 작용이 세분화된 경우(예: 모양 이동) 메모 기능이 매우 유용할 수 있습니다.

useCallback을 사용하여 함수를 캐싱하는 것은 다음과 같은 몇 가지 경우에만 가치가 있습니다.

  • memo로 래핑된 컴포넌트에 prop으로 전달합니다. 값이 변경되지 않은 경우 리렌더링을 건너뛰고 싶습니다. 메모를 사용하면 종속성이 변경된 경우에만 컴포넌트를 다시 렌더링할 수 있습니다.
  • 전달하는 함수는 나중에 일부 Hook의 종속성으로 사용됩니다. 예를 들어, useCallback에 래핑된 다른 함수는 이에 의존하거나 useEffect에서 이 함수에 의존합니다.

그 외에는 useCallback에서 함수를 래핑해도 이점이 없습니다. 그렇게 해도 큰 해가 되지 않으므로 일부 팀에서는 개별 사례에 대해 생각하지 않고 가능한 한 많이 메모하기로 결정합니다. 단점은 코드의 가독성이 떨어진다는 것입니다. 또한 모든 메모이제이션이 효과적인 것은 아닙니다. "항상 새로운" 단일 값은 전체 컴포넌트에 대한 메모이제이션을 중단하기에 충분합니다.

useCallback은 함수 생성을 방해하지 않습니다. 당신은 항상 함수를 생성하지만(괜찮습니다!), React는 이를 무시하고 아무것도 변경되지 않으면 캐시된 함수를 반환합니다.

실제로 다음 몇 가지 원칙을 따르면 많은 memoization를 불필요하게 만들 수 있습니다. (좋은 글이군요)

  1. 컴포넌트가 다른 컴포넌트를 시각적으로 래핑할 때 JSX를 children으로써 받아들이도록 합니다. 그러면 래퍼 컴포넌트가 자체 상태를 업데이트하면 React는 children을 다시 렌더링할 필요가 없다는 것을 알고 있습니다.
  2. 지역 상태를 선호하고 필요 이상으로 상태를 올리지 마십시오. forms과 같은 임시 상태를 유지하지 말고 항목이 트리 상단이나 전역 상태 라이브러리에 올려져 있는지 여부를 유지하지 마십시오.
  3. 렌더링 로직을 순수하게 유지하세요. 컴포넌트를 리렌더링하면 문제가 발생하거나 눈에 띄는 시각적 아티팩트가 생성되는 경우 컴포넌트의 버그입니다! 메모를 추가하는 대신 버그를 수정하세요.
  4. 상태를 업데이트하는 불필요한 Effects를 피하세요. React 앱의 대부분의 성능 문제는 컴포넌트가 계속해서 렌더링되도록 하는 Effects에서 발생하는 일련의 업데이트로 인해 발생합니다.
  5. Effects에서 불필요한 종속성을 제거해 보세요. 예를 들어, 메모하는 대신 일부 개체나 함수를 Effect 내부 또는 컴포넌트 외부로 이동하는 것이 더 간단한 경우가 많습니다.

특정 상호 작용이 여전히 느리게 느껴진다면 React 개발자 도구 프로파일러를 사용하여 어떤 컴포넌트가 메모 기능을 통해 가장 많은 이점을 얻을 수 있는지 확인하고 필요한 곳에 메모 기능을 추가하세요. 이러한 원칙을 사용하면 컴포넌트를 더 쉽게 디버그하고 이해할 수 있으므로 어떤 경우에도 이를 따르는 것이 좋습니다. 장기적으로는 이 문제를 완전히 해결하기 위해 자동으로 메모 기능을 수행하는 연구를 진행하고 있습니다.

Updating state from a memoized callback

때로는 메모된 콜백의 이전 상태를 기반으로 상태를 업데이트해야 할 수도 있습니다.

handlerAddTodo 함수는 다음 할 일을 계산하기 때문에 todos을 종속성으로 지정합니다.

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos([...todos, newTodo]);
  }, [todos]);
  // ...

일반적으로 메모된 함수에는 가능한 한 적은 종속성을 갖기를 원할 것입니다. 다음 상태를 계산하기 위해 일부 상태를 읽는 경우 대신 업데이트 함수를 전달하여 해당 종속성을 제거할 수 있습니다.

function TodoList() {
  const [todos, setTodos] = useState([]);

  const handleAddTodo = useCallback((text) => {
    const newTodo = { id: nextId++, text };
    setTodos(todos => [...todos, newTodo]);
  }, []); // ✅ No need for the todos dependency
  // ...

여기에서는 todos를 종속성으로 만들고 내부에서 읽는 대신 상태(todos => [...todos, newTodo])를 업데이트하는 방법에 대한 지침을 React에 전달합니다.

Preventing an Effect from firing too often

때로는 Effect 내부에서 함수를 호출하고 싶을 수도 있습니다:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  function createOptions() {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    // ...

이로 인해 문제가 발생합니다. 모든 반응 값은 Effect의 종속성으로 선언되어야 합니다. 그러나 createOptions를 종속성으로 선언하면 Effect가 채팅방에 지속적으로 다시 연결됩니다.

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 🔴 Problem: This dependency changes on every render
  // ...

이 문제를 해결하려면 Effect에서 호출해야 하는 함수를 useCallback으로 래핑하면 됩니다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); // ✅ Only changes when roomId changes

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // ✅ Only changes when createOptions changes
  // ...

이렇게 하면 roomId가 동일한 경우 다시 렌더링 간에 createOptions 함수가 동일하게 됩니다. 그러나 함수 종속성의 필요성을 제거하는 것이 훨씬 더 좋습니다. Effect 내에서 함수를 이동하세요.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() { // ✅ No need for useCallback or function dependencies!
      return {
        serverUrl: 'https://localhost:1234',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ Only changes when roomId changes
  // ...

이제 코드가 더 간단해지고 useCallback이 필요하지 않습니다.

Optimizing a custom Hook

custom Hook을 작성하는 경우, useCallback으로 반환되는 모든 함수를 래핑하는 것이 좋습니다.

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

이렇게 하면 Hook 소비자가 필요할 때 자신의 코드를 최적화할 수 있습니다.


Troubleshooting

Every time my component renders, useCallback returns a different function

종속성 배열을 두 번째 인수로 지정했는지 확인하세요! 종속성 배열을 잊어버린 경우 useCallback은 매번 새 함수를 반환합니다.

function ProductPage({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }); // 🔴 Returns a new function every time: no dependency array
  // ...

아래는 종속성 배열을 두 번째 인수로 전달하는 수정된 버전입니다.

function ProductPage({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]); // ✅ Does not return a new function unnecessarily
  // ...

이것이 도움이 되지 않는다면, 문제는 종속성 중 적어도 하나가 이전 렌더링과 다르다는 것입니다. 콘솔에 종속성을 수동으로 기록하여 이 문제를 디버깅할 수 있습니다.

  const handleSubmit = useCallback((orderDetails) => {
    // ..
  }, [productId, referrer]);

  console.log([productId, referrer]);

I need to call useCallback for each list item in a loop, but it’s not allowed

Chart 컴포넌트가 메모로 래핑되어 있다고 가정합니다. ReportList 구성 요소가 리렌더링될 때 목록의 모든 Chart가 리렌더링을 건너뛰려고 합니다. 그러나 루프에서 useCallback을 호출할 수는 없습니다.

function ReportList({ items }) {
  return (
    <article>
      {items.map(item => {
        // 🔴 You can't call useCallback in a loop like this:
        const handleClick = useCallback(() => {
          sendReport(item)
        }, [item]);

        return (
          <figure key={item.id}>
            <Chart onClick={handleClick} />
          </figure>
        );
      })}
    </article>
  );
}

대신, 개별 항목에 대한 구성요소를 추출하고 거기에 useCallback을 넣으세요.

function ReportList({ items }) {
  return (
    <article>
      {items.map(item =>
        <Report key={item.id} item={item} />
      )}
    </article>
  );
}

function Report({ item }) {
  // ✅ Call useCallback at the top level:
  const handleClick = useCallback(() => {
    sendReport(item)
  }, [item]);

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
}

또는 마지막 코드 조각에서 useCallback을 제거하고 대신 Report 자체를 메모에 래핑할 수도 있습니다. item prop이 변경되지 않으면 Report는 재렌더링을 건너뛰므로 Chart도 재렌더링을 건너뜁니다.

function ReportList({ items }) {
  // ...
}

const Report = memo(function Report({ item }) {
  function handleClick() {
    sendReport(item);
  }

  return (
    <figure>
      <Chart onClick={handleClick} />
    </figure>
  );
});

마치면서

상당히 중요한 내용이 많이 나오는군요. 특히 Deep Dive 부분은 면접 질문으로도 자주 나왔습니다. 잘 알아두면 좋겠군요.

  • How is useCallback related to useMemo?
  • Should you add useCallback everywhere?
profile
부계정

0개의 댓글