본문 바로가기

FRONTEND

useEffect

useEffect 소개

useEffect 훅은 콜백함수(setup 함수라 칭함)의존성 배열(dependencies 라고 칭함)을 인자로 받아 콜백 함수를 실행하는 훅입니다. useEffect 훅은 클래스형 컴포넌트의 생명주기를 함수형 컴포넌트에서 활용하는데 목적이 있습니다. 기본적으로 React가 dependencies의 변화 상태를 감지하여 setup 함수의 실행 시점과 실행 여부 등을 결정합니다.

 

useEffect 훅은 컴포넌트와 외부 시스템과 동기화를 하기 위해 사용한다고 말하기도 합니다. (Some components need to synchronize with external systems.) 여기서 말하는 외부 시스템이란, 비동기 서버 통신, React 컴포넌트가 아닌 외부 라이브러리를 상태를 기반하여 업데이트 해야 하는 경우 등을 말합니다.

React 컴포넌트의 생명주기

  • Mount - 컴포넌트가 실행될 때(화면에 첫 렌더링)
  • Update - 상태 등의 변화로 컴포넌트가 다시 실행 될 때(화면에 리 렌더링)
  • Unmount - 컴포넌트가 화면에서 사라질 때

 

형태

useEffect(setup, dependencies?)

setup

  • useEffect 훅에서 실행할 함수 입니다.(effect가 실행된다고 표현하기도 합니다.)
  • 컴포넌트가 DOM에 추가(Mount) 되고 화면에 그려진 후 setup 함수가 실행됩니다. (※ effect가 실행되는 시점을 꼭 기억해 주시길 바랍니다. 이 부분은 useLayoutEffect 훅을 설명하는데 중요한 기준이 됩니다)
  • cleanup 함수를 반환할 수 있습니다.
cleanup 함수란?
dependencies의 값이 변경되어 컴포넌트가 리렌더링(Update) 될 때 cleanup 함수가 실행 됩니다.DOM에서 해당 컴포넌트가 제거(Unmount) 되면 React는 반환된 cleanup 함수를 마지막으로 한 번 더 실행합니다.cleanup 함수 실행 시점 - 다음 렌더링과 새로운 effect를 실행하기 이전, 해당 컴포넌트 제거 될 때

dependencies

  • setup 함수에서 참조되고 있는 값(values)들과 props, 상태(state), 컴포넌트 내부에 선언된 모든 변수의 값이 dependencies에 들어갈 수 있습니다. 의존성 배열을 빈 배열로 한다면 컴포넌트가 렌더링 된 후 딱 한 번 setup 함수가 실행됩니다.

 

실행

useEffectsetup 함수가 실행되는 경우는 다음과 같이 3가지로 볼 수 있습니다.

의존성 배열(dependencies)1)넣지 않는 경우 2)빈 배열로 넣는 경우 3)값을 넣는 경우 입니다.

1) 컴포넌트가 렌더링 될 때마다 setup 함수를 실행

2) 렌더링시 setup 함수가 최초 1회 실행

3) 해당 상태 값이 변경될 때마다 setup 함수를 실행

 

useEffect를 구현해보자

ReactHooks 안에 useEffect 함수 선언하고 callback, dependencyArray를 인자로 설정하고 반환합니다.

const ReactHooks = (() => {
  //.. 생략
  const useEffect(callback, depnedencyArray) => {}
  
  return { useEffect };
}
)();

다음으로, 컴포넌트가 Mount시 최초 1회 callback 함수를 실행하는 로직을 작성해보겠습니다. hasChanged변수 선언하여 callback 함수의 실행 여부를 위한 flag로 사용합니다.

hasChanged: true -> callback 함수 실행 , hasChanged: false -> callback 함수 실행

const useEffect(callback, depnedencyArray) => {
  let hasChanged = true;
  
  if (hasChanged) {
    callback();
  }
}

의존성 배열이 있을 때, 의존성 배열의 값의 변화가 있을 경우 callback 함수의 재실행하는 로직을 작성해보겠습니다. 이전의 의존성 배열과 현재의 의존성 배열을 비교하기 위해서는 이전의 의존성 배열에 담긴 값을 알아야 합니다. 이전 값을 바탕으로 비교가 진행되어야 하므로 의존성 배열도 useState를 구현할 때와 마찬가지 방식으로 배열의 형태로 관리되어야 합니다. 기존에 작성된 let state변수를 hooks로 변경하겠습니다.

const ReactHooks = (() => {
    let hooks = [];
    let index = 0;
    
    //...생략
    
    const useEffect(callback, depnedencyArray) => {
      let hasChanged = true;
      
      if (hasChanged) {
        callback();
      }
    }
    
    return { useEffect }
})();

useEffect 훅 내부에서 hooks 배열의 마지막 인덱스에 인자로 들어온 depnedencyArray을 저장합니다. useState을 구현할 때와 마찬가지로 다음 훅이 호출될 경우 별도로 state 또는 의존성 배열이 독립적인 index에서 관리되어야 하기 때문에 index를 증가 시킵니다.

const ReactHooks = (() => {
  let hooks = [];
  let index = 0;
  
  //...생략
  
  const useEffect(callback, depnedencyArray) => {
    let hasChanged = true;
    
    if (hasChanged) {
      callback();
    }
    
    hooks[index] = depnedencyArray;
    index++;
  }
  
  return { useEffect }
})();

useEffect 훅 내부에 이전의 의존성 배열을 담은 oldDependencies 변수를 선언하고 인자를 통해 들어오는depnedencyArray(현재 의존성 배열)과 비교하는 로직 작성해 보겠습니다. oldDependencies(이전의 의존성 배열) 값은 초기에는 존재하지 않기 때문에 조건문을 통해 값이 있는 경우에만 비교할 수 있도록 합니다. 의존성 배열의 변화 여부에 따라 callback 함수 실행 여부가 결정되므로 같을 경우 callback 함수가 실행되지 않도록 hasChangedfalse를 할당합니다.

다음으로 depnedencyArray(인자를 통해 들어온 현재 의존성 배열)을 forEach문을 통해 순회하면서 oldDependencies 비교하겠습니다. forEach 문 내부에 oldDependency 변수를 선언하여 oldDependencies의 개별 요소들과 depnedencyArray 개별 요소들과 Object.is 메서드를 활용하여 비교합니다. areTheSame 변수(이전의 의존성 배열과 현재의 의존성 배열이 같은지 여부)를 통해 areTheSame = false일 경우, hasChanged=true 를 할당하여 callback 함수가 실행되도록 합니다.

const ReactHooks = (() => {

  let hooks = [];
  let index = 0;
  
  //...생략
  
  const useEffect(callback, depnedencyArray) => {
    let hasChanged = true;
    
    const oldDependencies = hooks[index];
    
    if (oldDependencies) {
      hasChanged = false;
      
      dependencyArray.forEach((dependency, index) => {
        const oldDependency = oldDependencies[index];
        const areTheSame = Object.is(dependency, oldDependency);
        
        if (!areTheSame) {
          hasChanged = true;
        }
      });
    }
    
    if (hasChanged) {
      callback();
    }
    
    hooks[index] = depnedencyArray;
    index++;
  }
  
  return { useEffect };
})();

완성된 코드는 다음과 같습니다.

const ReactHooks = (() => {
  let hooks = [];
  let index = 0;
  
  const useState = (initialValue) => {
    const localIndex = index;
    index++;
    
    if (hooks[localIndex] === undefined) {
      hooks[localIndex] = initialValue;
    }
    
    const setterFunction = (newValue) => {
      hooks[localIndex] = newValue
    };
   
   return [hooks[localIndex], setterFunction];
  };
  
  const resetIndex = () => {
    index = 0; 
  };
  
  const useEffect = (callback, dependencyArray) => {
    let hasChanged = true;
    
    const oldDependencise = hooks[index]; 
    
    if (oldDependencise) {
      hasChanged = false;
      
      dependencyArray.forEach((dependency, index) => {
        const oldDependency = oldDependencise[index];
        const areTheSame = Object.is(dependency, oldDependency);
        if (!areTheSame) {
          hasChanged = true;
        }
      });
    }
    
    if (hasChanged) {
      callback();
    }
    
    hooks[index] = dependencyArray;
    index++;
  };
  
  return {
    useState,
    resetIndex,
    useEffect,
  };
})();

아래 예제 코드를 통해 useEffect 훅이 의도한 대로 작동하는지 실행해보도록 하겠습니다.

const { useState, useEffect, resetIndex } = ReactHooks;
 
const Component = () => {
  const [counter, setCounter] = useState(1);
  const [name, setName] = useState("");
  
  console.log(counter);
  
  useEffect(() => {
    console.log("useEffect");
  }, [name]);
  
  if (counter !== 2) {
    setCounter(2);
  }
  
  if (name !== "Jack" && counter === 2) {
    setName("Jack");
  }
};
 
Component();
resetIndex();
Component();
resetIndex();
Component();

 

사용 유의 사항

  • 무한 렌더링(infinite loop)에 빠지는 경우

useEffect 훅 두 번째 인자로 의존성 배열을 전달하지 않을 경우, useEffect 내부에서 state(상태)를 변경한다면 무한렌더링 현상이 발생합니다. 의존성 배열이 전달되지 않으면 해당 effect는 매 렌더링마다 실행되기 때문입니다.

의존성 배열이 인자로 전달된 경우에도 무한렌더링 현상이 발생할 수 있는데 의존성 배열에 항상 변하는 값을 전달하는 경우에도 발생합니다.

  • 함수를 의존성 배열의 값으로 사용하면 안된다?

useEffect 내부(effect)에서 함수를 실행 시켜야 하는 경우가 있습니다(예를 들어, 비동기 data fetching).

function SearchResults() {
  const [data, setData] = useState({ hits: [] });

  async function fetchData() {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);
  // ... 생략

위와 같이 의존성 배열에 함수를 제외하는 상황을 의도적으로 만들어 사용기도 합니다. 함수는 객체이므로 렌더링이 될 때마다 동일한 함수 일지라도 참조하는 값이 다르기 때문에 다른 값(새로운 값)으로 인식됩니다. (매 렌더링마다 컴포넌트 내부의 함수는 바뀝니다.) 따라서 의존성 배열에 함수가 들어가게 된다면 매 렌더링마다 effect 내 함수가 계속해서 실행됩니다. 정리하자면 의존성 배열에 함수를 사용한다면 불필요한 상황에서도 계속해서 effect 내 함수가 지속적으로 실행된다는 문제가 발생합니다.

하지만 의존성 배열에서 함수를 제외 시키는 것을 통해 위 문제를 해결한다면 effect 내 함수의 규모가 커지는 경우, 또는 그 내부에서 또 다른 state 및 props를 참조하는 경우 effect 함수를 실행시켜야 할 모든 경우를 다루고 있다는 것을 보장할 수 없습니다.

function SearchResults() {
  const [query, setQuery] = useState('react');

  // 이 함수가 길다고 상상해 봅시다
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  // 이 함수가 길다고 상상해 봅시다
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []); //의존성 배열을 비워둔다면 변경되는 query 값에 따라 알맞은 data를 불러 올 수 없습니다.

  // ...
}

이 문제를 해결하는 직관적인 방법은 함수를 effect 내부로 옮기는 것입니다. 다음과 같이 말이죠!

function SearchResults() {
  const [query, setQuery] = useState('react');
  
  useEffect(() => {
    // 아까의 함수들을 안으로 옮겼어요!
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }
    fetchData();
  }, [query]); // ✅ 의존성 배열에 함수를 사용하지 않고 해결!
  // ...
}

하지만 어떤 경우에는 effect 내부에 함수를 넣는 것을 원하지 않을 수 있습니다. 동일한 함수를 다른 effect 내에서 호출해야 하는 경우 똑같은 함수 코드를 서로 다른 effect 내부에 작성해야 하는 번거로움이 있습니다. 또한 의존성 배열의 값을 함수나 빈 배열로 설정한다면 전자의 경우는 매 렌더링 마다 모든 effect가 불필요하게 실행된다는 문제가 후자의 경우는 정확히 effect의 실행을 통제할 수 없다는 문제가 있습니다.

//전자의 경우

function SearchResults() {
  // 🔴 매번 랜더링마다 모든 이펙트를 다시 실행한다
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다

  // ...
}

//후자의 경우
function SearchResults() {
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // 🔴 빠진 dep: getFetchUrl

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // 🔴 빠진 dep: getFetchUrl

  // ...
}

위와 같이 effect 내부에 함수를 넣는 것을 원하지 않는 다면 1)함수를 컴포넌트 외부로 빼는 것 2)useCallback 훅을 사용하는 것을 통해 해결할 수 있습니다.

1) 함수를 컴포넌트 외부로 빼는 것

// ✅ 데이터 흐름에 영향을 받지 않는다
function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // ✅ Deps는 OK

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // ✅ Deps는 OK

  // ...
}

2) useCallback 훅을 사용하는 것

function SearchResults() {
  // ✅ 여기 정의된 deps가 같다면 항등성을 유지한다
  const getFetchUrl = useCallback((query) => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []);  // ✅ 콜백의 deps는 OK
  
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // ✅ 이펙트의 deps는 OK

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // ✅ 이펙트의 deps는 OK

  // ...
}

 

소스 코드

의존성 배열 비교 관련

function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

const objectIs: (x: any, y: any) => boolean =
  // $FlowFixMe[method-unbinding]
  typeof Object.is === 'function' ? Object.is : is;

export default objectIs;
packages > react-reconciler > src > ReactFiberHooks.js

import is from 'shared/objectIs';

//..생략

function areHookInputsEqual(
  nextDeps: Array,
  prevDeps: Array | null,
): boolean {
  if (__DEV__) {
    if (ignorePreviousDependencies) {
      // Only true when this component is being hot reloaded.
      return false;
    }
  }

  if (prevDeps === null) {
    if (__DEV__) {
      console.error(
        '%s received a final argument during this render, but not during ' +
          'the previous render. Even though the final argument is optional, ' +
          'its type cannot change between renders.',
        currentHookNameInDev,
      );
    }
    return false;
  }

  if (__DEV__) {
    // Don't bother comparing lengths in prod because these arrays should be
    // passed inline.
    if (nextDeps.length !== prevDeps.length) {
      console.error(
        'The final argument passed to %s changed size between renders. The ' +
          'order and size of this array must remain constant.\n\n' +
          'Previous: %s\n' +
          'Incoming: %s',
        currentHookNameInDev,
        `[${prevDeps.join(', ')}]`,
        `[${nextDeps.join(', ')}]`,
      );
    }
  }
  // $FlowFixMe[incompatible-use] found when upgrading Flow
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

출처: https://github.com/facebook/react

useEffect 실제 사용 사례

  • 라이브러리, browser API, 네트워크 연결 등의 외부 시스템과의 연결이 필요할 때
  • 공통의 로직을 묶어 사용하는 custom hook을 사용할 때
  • 데이터 페칭

참고

https://overreacted.io/ko/a-complete-guide-to-useeffect/

https://beta.reactjs.org/reference/react/useEffect#usage