useCallback

Chaerin Kim·2023년 12월 3일

리렌더 사이에 함수 정의를 캐시할 수 있는 React Hook

const cachedFn = useCallback(fn, dependencies)

Reference

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는 초기 렌더링 중에 함수를 반환(호출X!)함. 다음 렌더링에서 React는 마지막 렌더링 이후 dependencies가 변경되지 않은 경우 동일한 함수를 다시 제공함. Dependencies가 변경된 경우 현재 렌더링 중에 전달받은 함수를 제공하고 나중에 재사용할 수 있도록 저장함. React는 함수를 호출하지 않음. 함수는 사용자에게 반환되므로 사용자는 언제 호출할지 결정할 수 있음.

  • dependencies: fn 코드 내에서 참조된 모든 반응형 값의 목록. 반응형 값에는 props, state, 컴포넌트 본문에 직접 선언된 모든 변수와 함수가 포함됨. Linter가 React에 대해 구성된 경우, 모든 반응형 값이 dependency로 올바르게 지정되었는지 확인함. Dependencies 목록에는 일정한 수의 항목이 있어야 하며 [dep1, dep2, dep3]와 같이 인라인으로 작성해야함. React는 Object.is 비교 알고리즘을 사용하여 각 dependency를 이전 값과 비교함.

Returns

초기 렌더링에서 useCallback은 사용자가 전달한 fn 함수를 반환함.

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

Caveats

  • useCallback은 Hook이므로 컴포넌트의 최상위 수준이나 자체 Hook에서만 호출할 수 있음. 루프나 조건 내부에서는 호출할 수 없음. 필요하다면 새 컴포넌트를 추출하고 state를 그 안으로 옮겨야함.

  • React는 특별한 이유가 없는 한 캐시된 값을 버리지 않음. 예를 들어, 개발 환경에서 컴포넌트의 파일을 편집할 때 React는 캐시를 버림. 개발과 프로덕션 환경 모두에서 컴포넌트가 initial mount 중에 일시 중단되면 React는 캐시를 버림. 향후 React는 캐시 버리기를 활용하는 더 많은 기능을 추가할 수 있음. ?예를 들어, 향후 React에 virtualized list에 대한 기본 지원이 추가되면 virtualized table 뷰포트에서 스크롤되어 넘어가는 항목에 대한 캐시를 버리는 것이 합리적일 것. 성능 최적화를 위해 useMemo에만 의존한다면 괜찮을 것. 또는 state 변수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]);
  
  // ...

useCallback은 두 개의 parameter를 받음:

  1. 리렌더링 사이에 캐시하려는 함수 정의.
  2. 함수 내에서 사용되는, 컴포넌트의 모든 값을 포함한 dependencies 목록.

초기 렌더링에서 useCallback은 전달받은 함수를 그대로 반환함.

이어지는 렌더링에서 React는 이전 렌더링의 dependencies와 현재 dependencies를 비교함. 'Dependencies'가 변경되지 않았다면(Object.is로 비교했을 때), useCallback은 이전과 동일한 함수를 반환함. 'Dependencies'가 변경되었다면, useCallback은 이 렌더링에서 전달받은 함수를 반환함.

다시 말해, useCallback은 dependencies가 변경될 때까지 재렌더링 사이에 함수를 캐시함.

이 기능이 유용한 경우는? ProductPage에서 ShippingForm 컴포넌트로 handleSubmit 함수를 전달한다고 가정:

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

theme prop이 변경되면 앱이 잠시 멈추는 것을 확인할 수 있지만, JSX에서 <ShippingForm />을 제거하면 앱이 빠르게 느껴짐. 이는 ShippingForm 컴포넌트를 최적화할 가치가 있다는 것을 의미함.

기본적으로 컴포넌트가 다시 렌더링될 때 React는 모든 자식을 재귀적으로 다시 렌더링함. 그렇기 때문에 ProductPage가 다른 theme로 다시 렌더링될 때 ShippingForm 컴포넌트도 다시 렌더링됨. 다시 렌더링하는 데 많은 계산이 필요하지 않은 컴포넌트는 괜찮음. 그러나 재렌더링이 느리다는 것을 확인했다면 ShippingFormmemo로 감싸서 props가 마지막 렌더링과 동일한 경우 재렌더링을 건너뛰도록 할 수 있음:

import { memo } from 'react';

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

이렇게 하면 ShippingForm은 모든 prop이 마지막 렌더링과 동일한 경우 재렌더링을 건너뜀. 바로 이때 함수 캐싱이 중요해짐! useCallback 없이 handleSubmit을 정의했다고 가정:

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에서 function () {} 또는 () => {}{} 객체 리터럴이 항상 새 객체를 생성하는 것과 유사하게 항상 다른 함수를 생성함. 일반적으로는 문제가 되지 않지만, 이는 ShippingForm props가 결토 동일하지 않으며 memo 최적화가 작동하지 않는다는 것을 의미함. 바로 이 지점에서 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>
  );
}

handleSubmituseCallback으로 감싸면 dependencies가 변경될 때까지 리렌더 간에 동일한 함수가 되도록 할 수 있음. 특별한 이유가 없는 한 함수를 useCallback으로 래핑할 필요는 없음. 이 예제에서는 memo로 감싸진 컴포넌트에 동일한 함수를 전달하면 리렌더링을 건너뛸 수 있기 때문에 useCallback을 사용한 것.

Note

useCallback은 성능 최적화를 위해서만 사용해야함. useCallback 없이 코드가 작동하지 않는다면 근본적인 문제를 찾아서 먼저 수정해야함. 그런 다음 useCallback을 다시 추가할 수 있음.

DEEP DIVE: How is useCallback related to useMemo?

useMemouseCallback을 함께 볼 수 있음. 둘 다 자식 컴포넌트를 최적화하고자 할 때 유용함. useMemouseCallback을 이용해 전달할 내용을 memoize (즉, 캐시)할 수 있음:

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가 변경되지 않는 한 requirements가 변경되지 않도록 computeRequirements(product)를 호출한 결과를 캐시함. 이를 통해 불필요하게 ShippingForm을 다시 렌더링하지 않고도 requirements 객체를 전달할 수 있음. 필요하다면 React는 렌더링 중에 전달한 함수를 호출하여 결과를 계산함.

  • useCallback은 함수 자체를 캐시함. useMemo와 달리 사용자가 제공한 함수를 호출하지 않음. 대신, 제공한 함수를 캐시하여 productId 또는 refereer가 변경되지 않는 한 handleSubmit 자체가 변경되지 않도록 함. 이렇게 하면 불필요하게 ShippingForm을 다시 렌더링하지 않고도 handleSubmit 함수를 전달할 수 있음. 사용자가 form을 제출할 때까지 코드가 실행되지 않음.

useMemo에 이미 익숙하다면 useCallback을 다음과 같이 생각하면 도움이 될 수 있음:

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

참고: useMemo와 useCallback의 차이점

DEEP DIVE: Should you add useCallback everywhere?

이 사이트처럼 대부분의 상호 작용이 페이지 또는 전체 섹션 교체와 같이 거칠게 이루어지는 앱의 경우 일반적으로 memoization이 필요하지 않음. 반면에 그림 에디터처럼 대부분의 상호 작용이 도형 이동과 같이 세분화되어 있는 앱의 경우 memoization이 매우 유용할 수 있음.

useCallback으로 함수를 캐싱하는 것은 몇 가지 경우에만 유용함:

  • memo로 감싸진 컴포넌트에 prop으로 전달할 때. 값이 변경되지 않은 경우 렌더링을 건너뛰고 싶을 때, memoization을 사용하면 dependencies가 변경된 경우에만 컴포넌트를 다시 렌더링할 수 있음.
  • 전달하는 함수가 나중에 일부 Hook의 dependency로 사용될 때. 예를 들어, useCallback으로 감싸진 다른 함수가 이 함수에 종속되거나, useEffect에서 이 함수에 종속되는 경우.

다른 경우에는 함수를 useCallback으로 감싸는 것의 장점이 없음. 그렇게 하는 것이 크게 해가 되는 것도 아니기 때문에 일부 팀에서는 개별 사례에 대해 생각하지 않고 가능한 한 많이 memoize하는 방식을 선택하기도 함. 단점은 코드의 가독성이 떨어진다는 것. 또한 모든 memoization이 효과적인 것은 아님. "항상 새로운" 하나의 값만으로도 전체 컴포넌트의 memoization이 깨질 수 있음.

useCallback이 함수 생성을 막지는 않는다는 점에 유의해야함. 우리는 항상 함수를 생성하지만(그리고 그것은 괜찮음!), React는 이를 무시하고 변경된 것이 없다면 캐시된 함수를 반환함.

다음의 몇 가지 원칙을 따르면 많은 memoization을 불필요하게 만들 수 있음:

  1. 컴포넌트가 다른 컴포넌트를 시각적으로 감쌀 때 JSX를 자식으로 받아들이도록 할 것. ?이렇게 하면 wrapper 컴포넌트가 자신의 상태를 업데이트할 때 React는 그 자식 컴포넌트가 다시 렌더링할 필요가 없다는 것을 알 수 있음.?
  2. 로컬 상태를 지향하고 필요 이상으로 상태를 끌어올리지 말 것. form의 현재 상태나 항목이 hover되었는지 여부처럼 일시적인 state를 트리의 맨 꼭대기나 전역 상태 라이브러리에 유지하지 말 것.
  3. 렌더링 로직을 순수하게 유지할 것. 컴포넌트를 다시 렌더링할 때 문제가 발생하거나 눈에 띄는 시각적 아티팩트가 생성된다면 컴포넌트에 버그가 있는 것! memoization을 추가하는 대신 버그를 수정할 것.
  4. 상태를 업데이트하는 불필요한 Effect는 피할 것. React 앱의 성능 문제는 대부분 컴포넌트를 반복해서 렌더링하게하는 Effect에서 비롯된 연쇄적 업데이트 때문에 발생함.
  5. Effect에서 불필요한 dependencies를 제거할 것. 예를 들어, memoization 대신 일부 객체나 함수를 Effect 내부 또는 컴포넌트 외부로 이동하는 것이 더 간단할 때가 많음.

특정 상호작용이 여전히 느리게 느껴진다면, React Developer Tools profiler를 사용해 어떤 컴포넌트가 memoization의 이점을 가장 많이 누릴지 확인하고, 필요한 경우 memoization를 추가할 것. 이러한 원칙은 컴포넌트를 더 쉽게 디버깅하고 이해할 수 있게 해주므로 어떤 경우든 이 원칙을 따르는 것이 좋음. 장기적으로는 이 문제를 완전히 해결하기 위해 memoization을 자동으로 수행하는 방법을 연구하고 있음.

Updating state from a memoized callback

때로는 memoize된 callback의 이전 state를 기반으로 state를 업데이트해야 할 수도 있음.

아래의 handleAddTodo 이전 todo를 기반으로 다음 todo를 계산하기 때문에 todo를 dependency로 지정함:

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

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

일반적으로 memoize된 함수는 가능한 한 dependencies가 적어야함. 다음 state를 계산하기 위해 일부 state를 읽어야 하는 경우, 대신 updater 함수를 전달하여 해당 dependency를 제거할 수 있음:

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

  // ...

여기서는 todo를 dependency로 설정하고 내부에서 읽는 대신 state를 업데이트하는 '방법'에 대한 명령(todos => [...todos, newTodo])를 React에 전달함.

참고: updater 함수

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의 dependency로 선언해야함. 그러나 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 함수가 동일하게 유지됨. 하지만 함수 dependency를 없애는 것이 더 좋음. 함수를 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이 필요하지 않음.

참고: Effect dependencies 제거

Optimizing a custom Hook

커스텀 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

두 번째 인수로 dependency 배열을 지정했는지 확인할 것!

Dependency 배열을 잊어버리면 useCallback은 매번 새로운 함수를 반환함:

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

이것은 dependency 배열을 두 번째 인수로 전달하면:

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

그래도 문제가 해결되지 않는다면 dependency 중 하나 이상이 이전 렌더링과 다를 수 있음. Dependencies를 콘솔에 수동으로 로깅하여 이 문제를 디버깅할 수 있음:

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

  console.log([productId, referrer]);

그런 다음 콘솔에서 서로 다른 리렌더에서 출력된 배열을 마우스 오른쪽 버튼으로 클릭하고 두 배열 모두에 대해 "Store as a global variable"을 선택하면 됨. 첫 번째 배열이 temp1로 저장되고 두 번째 배열이 temp2로 저장되었다고 가정하면 브라우저 콘솔을 사용하여 두 배열의 각 dependency가 동일한지 확인할 수 있음:

Object.is(temp1[0], temp2[0]); // Is the first dependency the same between the arrays?
Object.is(temp1[1], temp2[1]); // Is the second dependency the same between the arrays?
Object.is(temp1[2], temp2[2]); // ... and so on for every dependency ...

Memoization을 방해하는 dependency를 찾았다면 그 dependency를 제거할 방법을 찾거나 함께 memoize할 것.

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

Chart 컴포넌트가 memo로 감싸져 있다고 가정하면, 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 자체를 memo로 감쌀 수도 있음. item prop이 변경되지 않으면 Report는 리렌더링을 건너뛰므로 Chart도 리렌더링을 건너뜀:

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

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

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

0개의 댓글