리액트 이해하기] 04. useEffect훅 구현하기

Song-Minhyung·2024년 1월 1일

리액트 이해하기

목록 보기
4/8
post-thumbnail

0. 시작하기 앞서

실제 리액트의 코드는 이와 다릅니다.
제가 작성한 코드는 학습하기 위해 작성한 코드임을 미리 알립니다!
전체 코드는 깃허브 에서 확인 해보실 수 있습니다.

목차

1. useEffect는 뭘까?

공식문서에서는 아래와 같이 설명하고 있습니다.

위 내용을 정리하면 아래와 같습니다.
1. useEffect는 모든 렌더링이 종료된 후에 실행된다.
2. 의존성 배열이 없다면 렌더링 후에 실행된다.
3. 의존성 배열이 비었다면 마운트될 때 실행된다.
4. 의존성 배열이 들어있다면 마운트될 때, 의존성 배열의 값중 하나라도 변경되면 실행된다.

여기에 추가적으로 컴포넌트가 언마운트될 때 특정 작업을 하려면 클린업 함수를 아래처럼 작성해 주면 됩니다!

useEffect(()=>{
  // 클린업
  return () => {...};
});

만약 소켓을 연결하거나 이벤트 리스너를 추가하는 등의 행위를 useEffect 안에서 했다면 연결을 끊어줄 때 클린업을 해주면 됩니다. 그렇지 않으면 메모리 누수등의 문제가 발생할 수 있습니다.

이제 위 내용을 토대로 useEffect를 구현할 차례입니다.

2. useEffect 만들어보기

이전글처럼 idx를 기반으로 구현하기에 아래처럼 조건에 따라 컴포넌트를 렌더링하면 문제가 발생합니다. 그런데 이 문제 역시 모든 부분을 구현하고 리액트 코드를 살펴본 이후 해결해 볼 예정입니다.

// App.tsx
function App() {
  const [isShowComponent1, setIsShowComponent1] = useState(false);
  
  return (
    <>
      {isShowComponent1 ? <Component1/> : <Component2/>
    </>
  );
}

2-1. 구현해보기

위 내용들을 토대로 useEffect 함수를 만들어보면 아래와 같은 모습을 갖습니다.

const effects: { deps?: unknown[] }[] = [];
let effectsArrayAfterRender: { callback: () => void; isRuned?: boolean }[] = [];
let effectsIdx = 0;

export function runSideEffects(): void {
  effectsArrayAfterRender.forEach((effect) => {
    // 이미 실행된 effect라면 실행하지 않음
    if (effect?.isRuned) return;
    effect.isRuned = true;
    effect.callback();
  });

  effectsArrayAfterRender = [];
}

export default function useEffect(callback: () => void, deps?: unknown[]) {
  // 이전 deps와 현재 deps가 다른지 비교하는 함수
  const isDifferentDeps = (dep: unknown, idx: number) => dep !== deps?.[idx];

  // 최초 실행시에만 effects에 넣어줌.
  if (effects.length === effectsIdx) {
    effects.push({ deps });
    effectsArrayAfterRender.push({ callback });
  }
  // deps를 넣지 않았거나 ||
  // 이전 effect와 비교했을 때 deps의 값중 하나라도 다르다면
  else if (!deps || effects[effectsIdx]?.deps?.some(isDifferentDeps)) {
    effectsArrayAfterRender.push({ callback });
    effects[effectsIdx] = { deps };
  }

  effectsIdx++;
}

export function resetEffectIdx() {
  effectsIdx = 0;
}

2-2. 변수 살펴보기

const effects: { deps?: unknown[] }[] = [];
let effectsArrayAfterRender: { callback: () => void; isRuned?: boolean }[] = [];
let effectsIdx = 0;

effects는 useEffect가 처음 실행될 때 마다 여기에 넣어줍니다.
그리고 effect의 deps가 변경될 경우 이 배열 특정 idx의 deps를 변경 해 줍니다.

effectsArrayAfterRender는 렌더링이 종료된 후 실행될 effect들을 모아둔 배열입니다.
useEffect를 호출할 때 deps를 넣어주지 않거나, 변경될 경우 이 배열에 추가됩니다.

마지막으로 effectsIdx는 useEffect를 호출할 때마다 1씩 증가합니다.

2-3. 구현한 useEffect 살펴보기

if (effects.length === effectsIdx) {
    effects.push({ deps });
	effectsArrayAfterRender.push({ callback });
}

useState를 구현할때와 비슷합니다.
최초로 실행된 useEffect일 경우 effects에 넣어줍니다.
추가적으로 의존성 배열과 관계없이 effectsArrayAfterRender에 넣어 렌더링이 끝나면 실행될 수 있도록 해줍니다.

else if (!deps || effects[effectsIdx]?.deps?.some(isDifferentDeps)) {
    effectsArrayAfterRender.push({ callback });
    effects[effectsIdx] = { deps };
}

위의 else if문은 deps가 없거나(모든 렌더링때 실행) deps가 변경되었을 때 effectsArrayAfterRender에 넣어 함수들을 렌더링 후 실행해줍니다.

2-4. runSideEffects 살펴보기

export function runSideEffects(): void {
  effectsArrayAfterRender.forEach((effect) => {
    // 이미 실행된 effect라면 실행하지 않음
    if (effect?.isRuned) return;
    effect.isRuned = true;
    effect.callback();
  });

  effectsArrayAfterRender = [];
}

보면 isRuned가 true일 경우 실행하지 않도록 하고있습니다.
이유는 useEffect 안에서 state가 변경되면 아래와 같이 계속 순환됩니다.
useEffect 실행 -> beRunEffects에 등록 -> mounted -> beRunEffects실행 -> state변경으로 인한 리렌더링 ->
useEffect 실행 -> .... -> ... -> ...

그래서 이 순환고리를 끊어주기 위해서 effect를 실행하기전 체크를 해주고, 다음 주기에 해당 effect가 실행됐다면 해당 effect는 실행하지 않습니다.

3. useEffect가 렌더링 후에 실행되게 수정

맨 위에서 useEffect는 렌더링이 끝나고 실행 되어야 한다고 했습니다.
이를 위해서 useState에서 setState시 리렌더링 하는 부분을 함수로 분리 해주도록 하겠습니다.

3-1. render함수 작성

export function render() {
  // 리렌더링
  const root = document.querySelector("#root");
  const dom = React.createDOM(<App />);
  root?.replaceChildren(dom);

  // useEffect를 통해 등록된 함수들을 실행
  runSideEffects();
}

3-2. useState 수정

render함수가 실행되기 전에 idx를 초기화 해 주어야 하기때문에 resetStateIdx 함수를 추가적으로 작성 해 주었습니다.

...
function useState<T>(initialState: T): [T, SetState<T>] {
  ...

  const setState = (newState: T | SetStateFunction<T>) => {
    ...
    render();
  };
  ...
}

...

export function resetStateIdx() {
  stateIdx = 0;
}

3-3. render 함수 수정

리렌더링 하기 전에 useState와 useEffect의 idx들을 0으로 초기화 해주어야 정상적으로 작동합니다. 그러지 않으면 계속 배열의 idx가 증가해 이전 값을 가져올 수 없습니다.

export function render() {
  resetStateIdx();
  resetEffectIdx();
  ...
}

4. 방금 만든 useEffect 사용해보기

제대로 되는지 확인을 해볼려면 아래 세가지를 확인 해보면 됩니다.

  1. 의존성 배열이 없다면 렌더링 후에 실행되는지
  2. 의존성 배열이 비었다면 마운트될 때 실행되는지
  3. 의존성 배열이 들어있다면 마운트될 때, 의존성 배열의 값중 하나라도 변경되면 실행되는지

기존 App.tsx의 코드를 조금 추가해줍니다.

...
function App() {
  const [isCounter1Show, setIsCounter1Show] = useState(true);
  const toggleCounter = () => setIsCounter1Show((prev) => !prev);

  useEffect(() => {
    console.log("every render");
  });
  useEffect(() => {
    console.log("only first render");
  }, []);
  useEffect(() => {
    console.log("only first render and counter1 change");
  }, [isCounter1Show]);

  return (
    <div>
      <button onclick={toggleCounter}>카운터 토글</button>
      {isCounter1Show ? <Counter1 /> : <Counter2 />}
    </div>
  );
}

이제 이 코드를 테스트 해보면 아래와 같이 의도한대로 잘 작동하는걸 확인할 수 있습니다!

마무리

이제 중요하다고 생각하는 훅의 두가지가 마무리 되었습니다.
다음 글에서는 useRef 훅을 간단하게 구현한 후에
마지막으로 렌더링 최적화 하는 글에서 구현 해보기 시리즈는 마무리 하려고 합니다.

그 다음 시리즈에서 리액트를 구현 해보며 발생했던 문제들을
리액트의 코드를 살펴보며 해결하는 시간을 가져보도록 하겠습니다.

참고

https://ko.react.dev/reference/react/useEffect
https://ko.react.dev/learn/synchronizing-with-effects

profile
기록하는 블로그

0개의 댓글