공부 및 일상기록

useRef를 활용한 렌더링 방지 본문

개발/TIL WIL 공부목표

useRef를 활용한 렌더링 방지

낚시하고싶어요 2023. 6. 21. 21:23

Quiz를 푸는 웹사이트를 제작 중, Quiz를 풀기 시작하면 시간을 카운트하여 마지막 결과에서 얼마의 시간이 흘렀는지를 보여주는 기능이 필요했다.

 

나는 처음 다음과같은 hooks를 만들었다.

 

1. 잘못된 useTimer

import React, { useEffect, useState } from "react";
import { useSetRecoilState } from "recoil";
import { totalTimeAtom } from "../atom/atom";

export default function useTimer() {
  const [seconds, setSeconds] = useState(0);
  const setTotalTime = useSetRecoilState(totalTimeAtom);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    return () => {
      setTotalTime(seconds);
      clearInterval(timer);
    };
  }, [seconds]);
}

위 코드를 작성할 때 나의 생각은 다음과 같았다.

1. 나는 총 걸린 시간이 전역적으로 필요하다.

2. 지역변수로 업데이트 하다가 마지막 언마운트 될 때 한번만 전역변수로 업데이트한다.

3. useEffect의 의존성에 seconds를 넣지 않으면, 초기 useEffect가 호출될때의 컨텍스트만 기억하기 때문에 초기값인 0이 기록될 것이다.

 

그래서 의존성 배열에 seconds를 추가하게 되었고, 이는 다음과 같은 문제를 일으켰다.

 

useEffect가 매 초 다시 호출되고 언마운트 된다.

그러므로 setTotalTime이 매 초 바뀌게 된다.

 

결국 해당 useTimer 훅을 포함한 컴포넌트는 모두 재 렌더링이 발생해버리는 것이였다.

 

그래서 해결한 방법은 useRef를 사용하는 방법이였다.

 

2. 개선된 useTimer Hook

import React, { useEffect, useRef, useState } from "react";
import { useSetRecoilState } from "recoil";
import { totalTimeAtom } from "../atom/atom";

export default function useTimer() {
  const [seconds, setSeconds] = useState(0);
  const secondsRef = useRef(seconds);
  const setTotalTime = useSetRecoilState(totalTimeAtom);

  useEffect(() => {
    secondsRef.current = seconds;
  }, [seconds]);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    return () => {
      setTotalTime(secondsRef.current);
      clearInterval(timer);
    };
  }, []);
}

달라진 점은 setInterval이 존재하는 useEffect의 의존성을 제거하여 단 한번만 호출되도록 한것이다.

 

그럼 시간은 어떻게 최신으로 유지시켰을까?

 

바로 useRef를 사용한것이다.  

 

useRef는 current속성을 가지고 있는 객체를 반환하는데, 이는 인자로 넘어온 초기값을 current에 할당하고, current속성은 변경되어도 리액트 컴포넌트를 리렌더링 시키지 않는다. 또한 리액트 컴포넌트가 리렌더링 될 때 current속성의 값은 유실되지 않는다.

 

따라서 위 코드처럼 useEffect의 의존성 배열에 seconds를 넣고, useRef의 current에 해당 seconds를 할당하여 최신값을 유지한 것이다.

 

이렇게 하면 리코일로 선언한 전역변수인 setTotalTime()은 해당 컴포넌트가 언마운트될 때 한번만 실행되므로 내가 예상한대로 움직일 것이라고 생각했다.

 

하지만 문제는 하나 더 있었다.

 

해당 useTimer Hook을 QuizPage에서 직접 부른다면, 어차피 해당 컴포넌트에서 상태가 변하기 때문에 QuizPage의 전체 재 렌더링을 막을 수 없는 것이였다.

 

따라서 나는 useTimer를 사용하는 목적이 그저 반환값 없이 시간을 재고, 언마운트시 전역변수로 업그레이드 하는 것이므로 QuizPage의 자식 컴포넌트를 하나 생성하고 그곳에서 타이머를 생성했다.

 

리액트에서 재렌더링 되는 조건은 다음과 같다.

state나 props의 변경, 부모컴포넌트의 재 렌더링, 강제로 렌더링 시켰을때

 

리액트의  상태 흐름은 부모에서 자식으로 가기 때문에 자식 컴포넌트가 부모컴포넌트의 상태를 변경하지 않는 한 자식은 부모를 재렌더링 시키지 않아 위와같은 방법을 택한 것이다.

 

import React from "react";
import useTimer from "../../hooks/useTimer";

export default function Timer() {
  const timer = useTimer();
  return null;
}

이런 Timer라는 리턴값이 없는 컴포넌트를 만들고 아래 처럼 사용했다.

 

~~~
return (
    <PageLayout>
      <Timer />
      ~~~~~
      ~~~~
    </PageLayout>
    )

이렇게 QuizPage컴포넌트에서 자식으로 불러주면 더이상 Timer컴포넌트의 재렌더링이 부모인 QuizPage에 영향을 주지 않게 된다.

 

 


수정사항이 생겨서 또 수정하였다.

import React, { useEffect, useRef, useState } from "react";
import { useSetRecoilState } from "recoil";
import { totalTimeAtom } from "../atom/atom";

export default function useTimer(end: boolean) {
  const [seconds, setSeconds] = useState(0);
  const secondsRef = useRef(seconds);
  const setTotalTime = useSetRecoilState(totalTimeAtom);

  useEffect(() => {
    secondsRef.current = seconds;
  }, [seconds]);

  useEffect(() => {
    if (end) {
      setTotalTime(secondsRef.current);
    }
  }, [end]);

  useEffect(() => {
    const timer = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    return () => {
      clearInterval(timer);
    };
  }, [setTotalTime]);
}

기존에 언마운트 될 때 전역변수로 변경하게 된다면, 문제를 다 풀고 그 화면에서 오랜시간 머문다면 타이머가 계속해서 작동하여 언마운트될 때 업데이트 되는 문제이다.

 

따라서 문제를 다 푼 시점을 props로 받아 해당 props가 true인 경우에만 업데이트 하도록 변경하였다.