당신이 React Hooks을 몇 시간 이상 사용했다면 흥미로운 문제에 부딪혔을 것입니다. setInterval를 사용해도 예상대로 작동하지 않는다는 것입니다.
많은 사람들이 hook에 setInterval를 사용하는 것은 리액트의 약점이라고 지적했습니다.(Ryan Florence)
솔직히 이 분들 말이 일리가 있다고 생각합니다. 처음에는 혼란스럽습니다.
하지만 저는 이 문제를 Hook의 결함이라기보다는 React 프로그래밍 모델과 setInterval 사이의 불일치라고 생각하게 되었습니다. 클래스보다 React 프로그래밍 모델에 더 가까운 Hook은 이러한 불일치를 더욱 두드러지게 만듭니다.
서로 잘 작동하도록 하는 방법이 있지만 약간 직관적이지 않습니다.
이 글에서는 인터벌과 훅을 함께 사용하는 방법, 이 솔루션이 합당한 이유, 그리고 이 솔루션이 제공하는 새로운 기능에 대해 살펴볼 것입니다.
Just Show Me the Code
1초마다 증가하는 카운터를 소개합니다.
import React, { useState, useEffect, useRef } from 'react';
function Counter() {
let [count, setCount] = useState(0);
useInterval(() => {
// Your custom logic here
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
useInterval은 내장된 React Hook이 아니고 제가 직접 커스텀한 Hook입니다.
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]);
}
제 useInterval Hook은 인터벌을 설정하고 마운트 해제 후 인터벌을 클리어 시킵니다. 이는 컴포넌트 라이프 사이클에 연결된 setInterval과 clearInterval의 조합입니다. 프로젝트에 자유롭게 복사하여 붙여넣거나 npm에 넣으세요.
이것이 어떻게 동작하는지 관심이 없다면 지금 읽기를 중단해도 됩니다! 이 블로그 포스트의 나머지 부분은 React Hook에 대해 자세히 알아볼 준비가 된 분들을 위한 것입니다.
Wait What?! 🤔
댄, 이 코드는 말이 안 돼요. "그냥 자바스크립트"는 어쩌고요? React가 Hook과 함께 상어로 뛰어들었다는 사실을 인정하세요!
저도 그렇게 생각했지만 생각이 바뀌었고, 여러분의 생각을 바꾸려고 합니다. 이 코드가 왜 합당한지 설명하기 전에, 이 코드가 무엇을 할 수 있는지 보여드리고 싶습니다.
Whu useInterval() Is a Better API
다시 한 번 말씀드리자면, 제가 사용하는 useInterval Hook은 함수와 delay를 인자로 받습니다.
useInterval(() => {
// ...
}, 1000);
이것은 setInterval과 매우 유사합니다.
setInterval(() => {
// ...
}, 1000);
그러면 그냥 setInterval를 직접사용하면 안되나요?
처음에는 분명하지 않을 수 있지만, 여러분이 알고 있는 setInterval과 제가 사용하는 useInterval Hook의 차이점은 그 인자가 "동적"일는 점입니다.
구체적인 예를 들어서 이 점을 설명하겠습니다.
우리가 인터벌의 delay를 조정할 수 있다고 가정해보겠습니다:
입력으로 지연을 반드시 제어할 필요는 없지만, 사용자가 다른 탭으로 전환하는 동안 일부 AJAX 업데이트를 덜 자주 폴링하는 등 동적으로 조정하는 것이 유용할 수 있습니다. 그렇다면 클래스에서 setInterval로 어떻게 이 작업을 수행할 수 있을까요? 저는 이렇게 했습니다:
class Counter extends React.Component {
state = {
count: 0,
delay: 1000,
};
componentDidMount() {
this.interval = setInterval(this.tick, this.state.delay);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.delay !== this.state.delay) {
clearInterval(this.interval);
this.interval = setInterval(this.tick, this.state.delay);
}
}
componentWillUnmount() {
clearInterval(this.interval);
}
tick = () => {
this.setState({
count: this.state.count + 1
});
}
handleDelayChange = (e) => {
this.setState({ delay: Number(e.target.value) });
}
render() {
return (
<>
<h1>{this.state.count}</h1>
<input value={this.state.delay} onChange={this.handleDelayChange} />
</>
);
}
}
나쁘지 않습니다!
Hook 버전은 어떤 모습인가요?
function Counter() {
let [count, setCount] = useState(0);
let [delay, setDelay] = useState(1000);
useInterval(() => {
// Your custom logic here
setCount(count + 1);
}, delay);
function handleDelayChange(e) {
setDelay(Number(e.target.value));
}
return (
<>
<h1>{count}</h1>
<input value={delay} onChange={handleDelayChange} />
</>
);
}
네, 그 정도면 충분합니다.
클래스 버전과 달리, 동적으로 조정된 지연을 갖도록 useInterval Hook 예제를 업그레이드하는 데는 복잡성 차이가 없습니다.
인터벌 훅을 사용하면 다른 지연이 감지되면 인터벌을 다시 설정합니다.
간격을 설정하고 지우는 코드를 작성하는 대신 특정 지연이 있는 간격을 선언할 수 있으며, useInterval Hook이 이를 가능하게 합니다. 일시적으로 인터벌을 일시 중지하고 싶다면 어떻게 하나요? 아래와 같이 작업을 수행할 수 있습니다.
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
이것이 바로 제가 Hook과 React에 다시 한 번 열광하게 된 이유입니다.기존의 명령형 API를 감싸고 의도를 좀 더 밀접하게 표현하는 선언형 API를 만들 수 있습니다. 렌더링과 마찬가지로, 모든 시점의 과정을 조작하기 위해 세심하게 명령을 내리는 대신 모든 시점의 과정을 동시에 설명할 수 있습니다.
이 글을 통해 적어도 컴포넌트에서 사용할 때는 useInterval() Hook이라는 더 멋진 API를 알게되셨기를 바랍니다.
하지만 왜 Hook에서 setInterval()과 clearInterval()을 사용하는 것이 성가신 걸까요?
카운터 예제로 돌아가서 수동으로 구현해보겠습니다.
First Attempt
초기 상태만 렌더링하는 간단한 예제부터 시작하겠습니다:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
이제 매초마다 1씩 증가하기를 원합니다. cleanup이 필요한 side effect이므로 cleanup function을 반환합니다.
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <h1>{count}</h1>;
}
충분히 쉬워 보이시나요? 이렇게 하면 됩니다.
하지만 이 코드에는 이상한 동작이 있습니다.
React는 기본적으로 렌더링할 때마다 이펙트를 다시 적용합니다. 이는 의도적인 것이며 React 클래스 컴포넌트에 존재하는 클래스의 버그를 피하는데 도움이 됩니다.
많은 구독 API가 언제든지 기존 리스너를 제거하고 새 리스너를 추가할 수 있기 때문에 일반적으로 좋습니다. 하지만 setInterval은 그렇지 않습니다. clearInterval과 setInterval를 실행하면 그 타이밍이 바뀝니다. 이펙트를 너무 자주 다시 실행하고 다시 적용하면 인터벌은 실행될 기회를 얻지 못합니다.
컴포넌트를 더 작은 간격으로 렌더링하면 이 버그를 확인할 수 있습니다.
setInterval(() => {
// Re-renders and re-applies Counter's effects
// which in turn causes it to clearInterval()
// and setInterval() before that interval fires.
ReactDOM.render(<Counter />, rootElement);
}, 100);
Second Attempt
useEffect()를 사용하면 이펙트를 다시 적용하지 않도록 선택할 수 있다는 것을 알고 계실 것입니다. 두 번째 인자로 의존성 배열을 지정할 수 있으며, React는 해당 배열의 무언가가 변경될 때만 이펙트를 다시 실행합니다:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
마운트할 때만 Effect를 실행하고 마운트를 해제할 때 cleanup 함수를 실행하려면 빈 종속성 배열을 전달하면 됩니다. 하지만 이는 자바스크립트 클로저에 익숙하지 않은 경우 흔히 발생하는 실수입니다. 지금 바로 이 실수를 해보겠습니다!(또한 이러한 버그를 조기에 발견하기 위해 린트 규칙을 만들었습니다.)
첫 번째 시도에서 이펙트를 다시 실행하면 타이머가 너무 일찍 지워지는 문제가 발생했습니다. 이 문제를 해결하기 위해 이펙트를 다시 실행하지 않는 방법을 시도했습니다:
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
하지만 이제 카운터가 1로 업데이트되어 그 상태로 유지됩니다.
무슨 일이죠?!
문제는 useEffect가 첫 번째 렌더링에서 카운트를 캡처한다는 것입니다. 0과 같습니다. 우리는 effect를 다시 적용하지 않으므로 setInterval의 클로저는 항상 첫 번째 렌더링의 카운트를 참조하고 카운트 + 1은 항상 1이 됩니다.
훅은 정말 짜증나죠?
이 문제를 해결하는 한 가지 방법은 setCount(count + 1)를 setCount(c => c + 1)과 같은 updater 형식으로 바꾸는 것입니다. 이 함수는 항상 해당 변수의 새로운 상태를 읽을 수 있습니다. 하지만 이 방법은 예를 들어 새로운 props를 읽는데 도움이 되지 않습니다.
또 다른 수정 방법은 useReducer()를 사용하는 것입니다. 이 접근 방식은 더 많은 유연성을 제공합니다. 리듀서 내부에서는 현재 상태와 새로운 프로퍼티에 모두 엑세스할 수 있습니다. dispatch 함수 자체는 변경되지 않으므로 어떤 클로저에서든 데이터를 이 함수로 전달할 수 있습니다. useReducer()의 한가지 한계는 아직 부작용을 emit할 수 없다는 것입니다. (하지만 새 상태를 반환하여 일부 효과를 트리거할 수는 있습니다.)
그런데 왜 이렇게 복잡해졌을까요?
The Impedance Mismatch
이 용어는 때때로 던져지는데, 필 하그는 이를 다음과 같이 설명합니다:
데이터베이스는 화성에서 왔고 객체는 금성에서 왔다고 말할 수 있습니다. 데이터베이스는 객체 모델에 자연스럽게 매핑되지 않습니다. 마치 두 자석의 북극을 서로 밀어붙이려고 하는 것과 비슷합니다.
우리의 "Impedance Mismatch"는 데이터베이스와 객체 사이에 있는 것이 아닙니다. 이 불일치는 React 프로그래밍 모델과 명령형 setInterval API 사이에 있습니다.
React 컴포넌트는 잠시 동안 마운트되어 여러 가지 상태를 거칠 수 있지만 렌더링 결과는 모든 상태를 한 번에 설명합니다.
// Describes every render
return <h1>{count}</h1>
Hook을 사용하면 이펙트에 동일한 선언적 접근 방식을 적용할 수 있습니다:
// Describes every interval state
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
간격을 설정하는 것이 아니라 설정 여부와 지연 시간을 지정합니다. Hook이 이를 설정합니다. 연속적인 프로세스는 불연속적인 용어로 설명됩니다.
이와 대조적으로 setInterval은 프로세스를 시간 단위로 설명하지 않습니다. 일단 간격을 설정하면 간격을 clear 시키는 것 외에는 아무것도 변경할 수 없습니다. 이것이 바로 React 모델과 setInterval 사이의 불일치입니다.
React 컴포넌트의 props와 state는 변경될 수 있습니다. React는 이를 다시 렌더링하고 이전 렌더링 결과에 대한 모든 것을 잊어버립니다. 무의미해집니다.
useEffect() 훅 또한 이전 렌더링을 "잊어버립니다". 마지막 Effect를 정리하고 다음 Effect를 설정합니다. 다음 Effect는 새로운 props와 state에 닫힙니다. 이것이 첫 번째 시도가 간단한 케이스에서 효과가 있었던 이유입니다.
하지만 setInterva()는 잊어버리지 않습니다. 시간을 재설정하지 않고는 교체할 수 없는 이전 props와 state를 여전히 참조합니다.
아니면 기다릴 수 있을까요?
Refs to the Rescue!
문제는 이것으로 요약됩니다:
- 첫 번째 렌더링에서 callback1을 사용하여 setInterval(callback1, delay)를 수행합니다.
- 다음 렌더링에서 새로운 props와 state에 대해 닫혀있는 callback2가 있습니다.
- 하지만 시간을 재설정하지 않고는 이미 존재하는 이미 존재하는 interval를 대체할 수 없습니다!
그렇다면 interval를 전혀 바꾸지 않고 대신 최신 interval 콜백을 가리키는 가변 savedCallback을 도입하면 어떨까요?
이제 해결책을 알 수 있습니다:
- fn이 저장된 콜백을 호출하는 setInterval(fn, delay)를 만듭니다.
- 첫 번째 렌더링 후 저장된 콜백을 callback1로 설정합니다.
- 다음 렌더링 후 저장된 콜백을 callback2로 설정합니다.
- ???
- PROFIT
이 변경 가능한 저장된 콜백은 리렌더링 전반에 걸쳐 "Persist" 되어야 합니다. 따라서 일반 변수가 될 수 없습니다. 인스턴스 필드와 더 유사한 것이 필요합니다.
Hooks FAQ에서 알 수 있듯이, useRef()는 정확히 그 기능을 제공합니다.
const savedCallback = useRef();
// { current: null }
React의 DOM 참조에 익숙하실 것입니다. Hook은 가변 값을 보관할 때 동일한 개념을 사용합니다. ref는 무엇이든 넣을 수 있는 "상자"와 같습니다.
useRef는 렌더링 간에 공유되는 변경 가능한 current 프로퍼티가 있는 일반 객체를 반환합니다.
function callback() {
// Can read fresh props, state, etc.
setCount(count + 1);
}
// After every render, save the latest callback into our ref.
useEffect(() => {
savedCallback.current = callback;
});
그런 다음 인터벌 내부에서 이를 읽고 호출할 수 있습니다:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
[] 덕분에 Effect가 다시 실행되지 않고 interval이 초기화되지 않습니다. 하지만 savedCallback ref 덕분에 마지막 렌더링 후에 설정한 콜백을 언제든지 읽고 interval tick에서 콜백을 호출할 수 있습니다.
다음은 완전한 작동 솔루션입니다:
function Counter() {
const [count, setCount] = useState(0);
const savedCallback = useRef();
function callback() {
setCount(count + 1);
}
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
Extracting a Hook
물론 위 코드는 혼란스러울 수 있습니다. 정반대의 패러다임을 혼합하는 것은 마음을 뒤 흔드는 일입니다. mutatble refs를 엉망으로 만들 가능성도 있습니다.
Hook은 클래스보다 낮은 수준의 프리미티브를 제공하지만, 더 나은 선언적 추상화를 구성하고 생성할 수 있다는 장점이 있습니다.(I think Hooks provide lower-level primitives than classes -- but their beauty is that they enable us to compose and create better declarative abstractions.)
이상적으로는 그냥 이렇게 쓰고 싶어요:
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
ref mechanism의 본문을 복사하여 커스텀 Hook에 붙여넣겠습니다:
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]);
}
이 useInterval은 내장된 React Hook이 아니라 제가 직접 작성한 커스텀 Hook입니다:
function useInterval(callback) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
}
현재 1000 delay는 하드 코딩되어 있습니다. 이를 인수로 만들고 싶습니다:
function useInterval(callback, delay) {
저는 interval를 설정할 때 사용할 것입니다:
let id = setInterval(tick, delay);
이제 렌더링 간에 delay 변경이 발생될 수 있으므로 인터벌 효과와 dependency에서 delay을 선언해야합니다:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
잠시만요, interval 효과의 재설정을 피하고 싶어서 []를 구체적으로 전달하지 않았나요? 그렇지 않습니다. 콜백이 변경될 때만 재설정을 피하고 싶었던 것입니다. 하지만 delay가 변경되면 타이머를 다시 시작하고 싶습니다! 코드가 제대로 동작하는지 확인해보겠습니다:
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
function useInterval(callback, delay) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
그렇습니다. 이제 어떤 컴포넌트에서든 Interval()를 사용할 수 있으며 구현 세부 사항에 대해 너무 많이 생각하지 않아도 됩니다.
Bonus: Pausing the Interval
delay로 null을 전달하여 interval를 일시 중지할 수 있기를 원한다고 가정해 보겠습니다.
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
이를 어떻게 구현할 수 있을까요? 정답은 interval를 설정하지 않는 것입니다.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
그게 다입니다. 이 코드는 delay 변경, 일시 중지, 재개 등 가능한 모든 전환을 처리합니다. useEffect() API는 setup과 cleanup을 하는데 더 많은 노력을 기울여야 하지만 새로운 사례를 추가하는 것은 쉽습니다.
Bonus: Fun Demo
이 useInterval() Hook은 정말 재미있게 사용할 수 있습니다. 부작용이 선언적일 때 복잡한 동작을 함께 조율하는 것이 훨씬 쉽습니다.예를 들어 한 가지 지연을 다른 간격으로 제어할 수 있습니다:
function Counter() {
const [delay, setDelay] = useState(1000);
const [count, setCount] = useState(0);
// Increment the counter.
useInterval(() => {
setCount(count + 1);
}, delay);
// Make it faster every second!
useInterval(() => {
if (delay > 10) {
setDelay(delay / 2);
}
}, 1000);
function handleReset() {
setDelay(1000);
}
return (
<>
<h1>Counter: {count}</h1>
<h4>Delay: {delay}</h4>
<button onClick={handleReset}>
Reset delay
</button>
</>
);
}
Closing Thoughts
Hook은 익숙해지는 데 시간이 걸리며, 특히 명령형 코드와 선언형 코드의 경계에서 더욱 그렇습니다. React Spring 과 같이 강력한 선언적 추상화를 만들 수 있지만, 가끔 신경이 쓰일 수 있습니다.
지금은 Hook의 초기 단계이며, 아직 해결하고 비교해야 할 패턴이 분명히 존재합니다. 잘 알려진 "모범 사례"를 따르는데 익숙하다면 서둘러 Hook을 도입하지 마세요. 아직 시도해야하고 발견해야 할 것이 많습니다.
이 포스팅이 Hook과 함께 setInterval()과 같은 API를 사용하는 것과 관련된 일반적인 함정과 이를 극복하는 데 도움이 될 수 있는 패턴, 그리고 그 위에 더 표현력이 풍부한 선언적 API를 만드는것을 이해하는 데 도움이 되었기를 바랍니다.