React setInterval, setTimeout 이상해

react 타이머를 다룰 때 의도한 대로 동작을 잘 하지 않을 것이다.
React의 프로그래밍 모델과 setInterval 사이의 불일치로 보는 사람도 많다.

setInterval - useEffect

Q.

프로모션 오픈 시간에 맞춰 카운트 다운을 표시하고 그 시간이 되면 구매 버튼이 활성화 되어야 한다면 어떤 방법이 좋을까요? 어찌어찌 만들 수는 있지만 렌더링이 자주 일어날텐데 말이죠..

목표 시간 까지 얼마나 남았는지에 따라 달라지는 View를 만들고 싶은데 이럴떄는 어떻게 시간 데이터를 다루어야 할까?

A.

1. 입력과 출력으로 생각

- View를 만들때 무엇이 입력이고 무엇이 출력인지 따져보자. 
- 만드려는 UI의 모습이 출력이고
- 이 출력을 바꾸게 만드는 값이 입력이다. 
  • 입력 : 목표시간까지 얼마나 남았나 (남아있는 시간)

2. count로 바꿔서 예시

function Counter(){
  const [count, setCount] = useState(0);
  
  setInterval(()=>{
    setCount(count + 1);
  },1000);
}

이렇게 하면 문제가 생긴다.
매 초마다 새로운 타이머가 생겨나게 되고 그렇게 되면 여러개의 타이머가 계속 생성(새로 생성되는 타이머는 0+1만 하는 새로운 타이머)이 되므로 원치 않는 View를 보게된다.

3. useEffect 사용 (sideEffect를 다룰 때 사용하는 hook)

키워드 : 사이드이펙트
출력이 직접적으로 바뀌는 것이 아니라 count의 입력을 바꾸는 sideEffect 이다.
sideEffect 를 분리하는 이유는 입력이 바뀌면 출력을 위해 렌더링을 계속 시키게 하지 않기 위해서 이다.

function Counter(){
    const [count, setCount] = useState(0);
    useEffect(() => {
      const intervalEvent = setInterval(() => {
        setCount(value => value + 1);
      }, 1000);
      return () => clearInterval(intervalEvent);
    },[])
    return (<h1>{count}</h1>);
}

위에서

setCount(value => value + 1);
의존성 배열 empty

를 한 이유는 setInterval을 셋팅하는 함수는 딱 한번만 하고(mount 될 때만),
closure에 의한 count 값을 받는게 아니라 setInterval에서 처음 셋팅 할 떄 만들었던 value를 받기 위함이다.

4. useInterval 써라!

function Counter(){
    const [count, setCount] = useState(0);
  
    useInterval(() => {
      setCount(count + 1);
    }, 1000)
    return (<h1>{count}</h1>);
}
  1. 사용자 입장에서 클로저를 신경 쓰지 않아도 된다.
  2. 타이머를 바꾸기 위해 이래저래 복잡한 것을 신경 쓰지 않아도 된다.
  3. 렌더링 시점에 해야 할 일만 묘사하면된다. (선언적)

useInterval 내부

import React, { useState, useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // Remember the latest callback.
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

받은 콜백을 별도로 저장을 해놓고, 내부 코드의 클로저로 savedCallback을 사용한다.
그래서 View를 만드는 입장에서는 클로저 신경 쓸 필요 없이 렌더링 시점에 대한 상태에 대해서만 코드를 짜면 된다.

setTimeout

이용자가 버튼을 눌렀을 때 1초 뒤에 카운트가 되는 것을 만들어 주세요

입력 값은 count 이고 이건 사용자에 의해 변한다.

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  const delayIncrease = () => {
    setTimeout(() => {
      setCount((value) => value + 1);
    }, 1000);
  };
  
  return (
    <div className="App">
      <h1>{count}</h1>
      <button onClick={delayIncrease}>누르면 숫자 올라감 </button>
    </div>
  );
}

이용자의 액션에 의한 값이라 사이드이펙트가 아니라고 생각을 하고 따로 useEffect를 사용하지 않았다.

리액트에서의 사이드 이팩트란?

리액트는 state, props를 기반으로 UI를 그려내는 라이브러리이다.
렌더링의 결과물은 UI요소이고 영향을 주는 요소는 state와 props이다.
(state, props) => UI
하지만, input - ouput 이외의 다른 값을 조작한다면 SideEffect가 있다고 표현한다.
대표적으로 Data Fetching, DOM에 직접접근(EventListener 등록), 구독(setInterval) 과 같은 행위가 있다.

참고