UseEffect 완벽 가이드 (내맘대로 요약 3) (By. Dan Abramov)

Fizz·2022년 9월 15일
0
post-custom-banner

https://overreacted.io/ko/a-complete-guide-to-useeffect/
요약이다 3탄!!

액션을 업데이트로부터 분리하기

2번의 예제에 상태를 더하겠다

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

우리가 원하는대로 정확히 동작한다. step이 변경되면 interval은 다시 돌아간다.
하지만 step 이 바뀐다고 인터벌 시계가 초기화되지 않는 것을 원한다면 어떻게 해야 할까? 의존성 배열에서 step 을 제거하려면 어떻게 해야할까?

어떤 상태 변수가 다른 상태 변수의 현재 값에 연관되도록 설정하려고 한다면, 두 상태 변수 모두 useReducer 로 교체해야 할 수 있다.

위와 같이 setA(A=> ..) 식이라면 리듀서를 생각해 봄직 하다.
리듀서는 컴포넌트 내부 액션 표현과 그 반응으로 상태가 업데이트 되는걸 분리한다. 위 이펙트 안에서의 step의존성을 바꾸어 보려고 한다.

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // setCount(c => c + step) 대신에
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

좋아진점이..? 할 수 있다. 하지만 컴포넌트가 있는 한 dispatch 함수가 같다는 걸 보증한다. 따라서 인터벌을 구독하지 않아도 된다. (리액트가 dispatch, setState, useRef의 컨테이너값이 항상 고정되어 있다는 것을 보장 >> 의존성 배열에서 제거 가능 but 유지도 괜찮다.)

이펙트 안에서 상태를 읽는 대신 무슨 일이 일어났는지 알려주는 정보를 인코딩하는 액션을 Dispatch 후 이펙트는 step 상태로부터 분리된다. 이펙트는 어떻게 상태를 업데이트 할지 신경쓰지 않고, 단지 무슨 일이 일어났는지 알려주고, 리듀서가 업데이트 로직을 모아둔다.

왜 useReducer가 Hooks의 치트 모드인가

이전 상태 기준으로 상태를 설정할때 의존성 제거 방법을 봤다.
하지만 다음 prop이 상태설정에 필요하다면??

<리듀서 그 자체를 컴포넌트 안에 정의하여 props를 읽도록 하면 됩니다.>

function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

리듀서를 그대로 안에 정의해, prop을 읽을 수 있게 했다.
또한 렌더링간 dispatch의 동일성은 여전히 보장된다.
그래서 의존성을 제거할 수 있다.

다른 렌더링에 포함된 이펙트 안에서 호출된 리듀서가 Prop을 알고 있는 이유는 dispatch를 할때에 있다.
리액트는 액션을 그냥 기억해놓는다. 다음 렌더링에 리듀서를 호출하고 이 시점에서는 새 prop이 스코프안에 들어오고 이펙트 내부와 상관없게 된다.
useReducer는 업데이트 로직과 무엇이 일어나는지 서술하는 걸 분리하게 해준다.

함수를 이펙트 안으로 옮기기

흔히 하는 실수가 함수는 의존성에 포함되면 안된다는 거다.

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();
  }, []); // 이거 괜찮은가?
  // ...

나도 많이 썻던 함수 유형이다
이 코드는 동작하지만 단순 로컬함수를 의존성에서 제외하는 것은, 컴포넌트가 커지면 모든 경우를 다루기 힘들다고 한다.

예를 들면

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();
  }, []);

  // ...
}

이런 경우 deps를 업데이트 하지 않는다면, 이펙트는 prop과 state변화 동기화에 실패할것이다.
하지만 함수를 이펙트 안으로 옮긴다면 해결 가능한 문제다.

unction 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]); // ✅ Deps는 OK
  // ...
}

하지만 저는 이함수를 이펙트 안에 넣을 수 없어요

만약 넣고 싶지않다면 의존성을 정의하지 말아야 할까?
아니다. 이펙트는 의존성에 대해 거짓말을 하면 안된다.
흔한 오해가 함수는 바뀌지 않는다는건데, 사실 매 렌더링마다 바뀐다. 또, 이로인해 문제가 생긴다. 두 이펙트가 같은 함수를 호출한다면

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

  // ...
}
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 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

  // ...
}

저 함수는 렌더링 스코프에 포함되지도, 데이터 흐름과도 무관하기 때문에 dep에 명시할 필요가 없다. prop이나 state를 사용할 수도 없다.
다른 대안으로는 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 SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchUrl = useCallback(() => { // No query argument
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []); // 🔴 빠진 의존성: query
  // ...
}

useCallback은 의존성 체크에 레이어를 하나 더 단다.
즉 의존성을 피하기 보다 함수 자체가 필요할 때만 바뀔 수 있게 한다.

이렇게 접근한다면 아까 예시의 경우는 react redux 두검색 결과를 보여준다. 하지만 입력을 받는 부분을 만들어 query를 검색할 수 있다 해보자. 그래서 쿼리를 인자로 받는 대신 함수가 지역 상태로 이를 읽어들이고, 즉시 query의존성이 빠진걸 알게 된다. 이때 useCallback deps에 query를 추가하면
함수를 이용하는 어떠한 이펙트라도 query가 바뀔때 실행 될 것이다.

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

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

  // ...
}

부모로부터 함수 prop을 내려보내는것도 같은 해결책으로 풀 수 있다.

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

  // ✅ query가 바뀔 때까지 항등성을 유지한다
  const fetchData = useCallback(() => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
    // ... 데이터를 불러와서 리턴한다 ...
  }, [query]);  // ✅ 콜백 deps는 OK
  return <Child fetchData={fetchData} />
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ 이펙트 deps는 OK

  // ...
}

fetchData 는 오로지 Parent 의 query 상태가 바뀔 때만 변하기 때문에, Child 컴포넌트는 앱에 꼭 필요할 때가 아니라면 데이터를 다시 페칭하지 않을 것이다.

profile
성장하고싶은 개발자
post-custom-banner

0개의 댓글