Dan abramov의 https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 번역입니다.
All copyrights to Dan Abramov
translated by Jake seo
THERE IS NO EVEN SINGLE COMMERCIAL PURPOSE
리액트 훅스를 몇시간정도 가지고 놀았다면, 흥미로운 문제 하나를 만났을 수 있습니다. setInterval
을 사용했을 때 우리가 예상한대로 작동하지 않는 문제입니다.
Ryan Florence의 말을 인용하면,
많은 사람들이 hooks에서 setInterval을 사용하는 것에 대해 지적합니다. (원문 : 리액트의 얼굴에 묻은 계란처럼 취급합니다.)
사실, 이 사람들은 문제가 뭔지 알고 있는 겁니다. 처음에는 hooks에서 setInterval을 사용하는 것이 매우 헷갈립니다.
하지만 저는 이 문제를 리액트의 결함으로 보기보다는 리액트 프로그래밍 모델과 setInterval의 부조화로 봅니다. 클래스보다 더 리액트 프로그래밍 모델에 가까운 Hooks는 그러한 부조화를 더욱 두드러지게 만드는 거죠.
이 두가지를 함께 잘 작동시킬 방법이 분명 있습니다만, 이 방법이 직관적이진 않습니다.
이 포스팅에서, 우리는 Interval과 Hooks가 잘 어우러지게 하는 방법과 왜 이 방법이 맞는지 그리고 이러한 방법이 여러분들에게 어떤 새로운 기능을 제공할지에 대해 살펴볼 것입니다.
이 포스팅은 비정상적인 경우에 대해 다룹니다. 심지어 API가 수많은 유즈 케이스를 간소화한다해도, 이 논의는 어려운 케이스에만 집중할 것입니다.
만일 훅스 초보자라면, 앞으로 할 얘기에서 문제점이 무엇인지 모를 수도 있습니다. 훅스 소개서와 문서를 먼저 확인해주세요. 이 포스팅은 몇시간 이상 훅스를 다뤄본 사람들을 대상으로 합니다.
여기 매 초마다 증가하는 카운터가 있습니다.
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
은 빌트인 리액트 훅스가 아닙니다. 제가 작성한 커스텀 훅입니다.
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
훅은 interval을 세팅하고, 컴포넌트가 언마운트될 때, interval을 클리어합니다. setInterval
과 clearInterval
이 모두 컴포넌트 라이프사이클에 붙어있습니다.
위 코드를 프로젝트에 복붙하고 싶거나 npm에 올리고 싶다면 마음대로 하세요.
어떻게 동작하는지에 대해서는 별로 알고싶지 않다면, 지금 이 글을 그만 읽어도 상관없습니다.! 포스트의 나머지 내용은 리액트 훅스에 대해 깊게 알고싶어하는 친구들을 위한 내용입니다.
아마 이런 생각을 하고 있겠죠?
댄, 이 코드는 전혀 타당하지 않아요. 바닐라 자바스크립트에 무슨 일이 일어난거죠? 리액트가 훅스를 사용하면서 기본적인 상식을 벗어났다는 것을 인정하세요!
나도 이러한 생각을 했었지만, 지금은 생각을 바꾸었습니다. 그리고 여러분의 생각도 바꾸어보겠습니다. 이 코드가 왜 타당한지 설명하기 전에 이 코드가 무엇을 할 수 있는지 자랑좀 할게요.
useInterval()
이 더 나은 API일까요?useInterval
훅은 함수와 딜레이를 받습니다.
useInterval(() => {
// ...
}, 1000);
setInterval
과 매우 똑같습니다.
setInterval(() => {
// ...
}, 1000);
그럼 왜 setInterval을 그냥 쓰면 안될까요?
처음에는 설명이 명확하지 않을 수 있지만, 둘의 차이는 인자가 "동적" 이라는 차이가 있습니다.
예제와 함께 설명해보겠습니다.
interval이 변경 가능하다는 점에 대해 알아봅시다.
딜레이를 꼭 조정할 필요는 없겠지만, 딜레이를 동적으로 조정하는 것은 도움이 될 수도 있습니다. 예를들면, 사용자가 웹 사이트의 다른 탭에 방문해있는 동안에는 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} />
</>
);
}
}
음 나쁘지 않네요.
훅스 버전은 어떻게 생겼을까요?
🥁🥁🥁
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
훅 예제가 동적으로 조절 가능한 딜레이를 얻기 위해서 복잡한 갭이 필요 없습니다.
// Constant delay
useInterval(() => {
setCount(count + 1);
}, 1000);
// Adjustable delay
useInterval(() => {
setCount(count + 1);
}, delay);
useInterval
훅이 다른 딜레이를 가질 때, 인터벌을 새로 설정합니다.
interval을 세팅하고 클리어하는 코드를 작성하는 대신에, useInterval
훅을 이용하면 특정한 딜레이를 가진 interval을 선언할 수 있습니다.
만일, 일시적으로 interval을 멈추고 싶다면 어떻게 구현할까요? state를 이용하여 이것도 가능합니다.
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
위와 같은 코드 때문에 리액트와 훅스가 다시 한번 재밌게 느껴집니다. 명령적(Imperative) 프로그래밍 방식으로 작성된 API를 감싸서 우리의 의도를 잘 표현한 더욱 직관적인 선언적(declarative) API로 변환시킬 수 있습니다.
더 좋은 API인 useInterval()
가 더 많이 쓰이길 바랍니다. 적어도 컴포넌트에서 작업을 할 때는요.
근데 setInterval()
과 clearInterval()
을 쓰는 게 왜 짜증나는 일일까요? 다시 카운터 예제로 돌아가서 천천히 구현해봅시다.
초기 상태를 렌더링하는 간단한 예제부터 시작합시다.
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
매 초마다 증가하는 interval을 만들 것입니다. 그런데 이 예제는 cleanup이 필요한 부작용이 있기 때문에, useEffect()
를 써서 cleanup 함수를 반환할 것입니다.
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <h1>{count}</h1>;
}
쉬워보이죠? 이렇게 해도 동작하긴 합니다.
하지만, 이 코드에는 약간 이상한 동작이 있습니다.
리액트는 기본적으로 매 렌더링 이후에 효과들을 재적용합니다. 이런 동작은 의도된 것이고, 이러한 버그들을 피하는 것을 도와줍니다.
많은 구독 API들이 기꺼이 오래된 리스너를 삭제하고 새로운 리스너를 언제든 추가할 것입니다. 하지만, setInterval
은 이러한 예 중의 하나가 아닙니다. clearInterval
과 setInterval
을 수행할 때, 타이밍이 어긋납니다. 만일 우리가 너무 많이 재렌더링하고 효과를 재적용하면, interval은 동작할 기회를 얻지 못할 것입니다.
컴포넌트를 더 작은 interval로 재렌더링하면 이러한 버그를 볼 수 있습니다.
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);
useEffect()
는 효과를 재적용하는 것을 막아준다는 것을 알고 있을 것입니다. 두번째 인자에 배열로 디펜던시를 주면, 리액트는 디펜던시에 걸린 배열이 변경되었을 때만 효과를 재적용합니다.
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
마운트될 때, 동작하고 언마운트될 때, cleanup되길 원한다면, 두번째 인자 배열 []
에 아무것도 넣지 않으면 됩니다.
하지만, 만일 자바스크립트 클로저에 친숙하지 않다면, 일반적으로 이와 같은 실수를 저지르게 되는데, 이런 실수가 뭔지 지금 바로 구현해볼 것입니다. (이러한 버그를 조기에 발견하기 위해서 린트 규칙 또한 만들었습니다.)
첫 시도에서의 문제는 이펙트를 재실행하는 것이 타이머를 너무 빨리 clear시켰습니다. (언마운트 될 때마다 clear시키기 때문이죠.) 이펙트를 다시 재실행시키지 않는 방법으로 한번 해결해봅시다.
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
하지만, 이젠 카운터가 1로 업데이트 된 채로 변화하지 않습니다.
무슨 일이 벌어진걸까요?!
문제는 useEffect
가 count
를 첫 렌더에서 잡아버리는 현상 때문에 일어납니다. 첫 렌더에서 count
는 0
입니다. 이펙트를 재적용하지 않아서 setInterval
에 있는 클로저가 항상 첫 렌더의 count
를 참조합니다. 그리고 count + 1
은 계속 1
이 되는 것이죠.
이가 갈리는 소리가 들립니다. 훅스는 너무 짜증나는 녀석인 것 같습니다.
이 현상을 해결하기 위한 한 가지 해결법은 setCount(count + 1)
을 setCount(c => c + 1)
과 같이 "업데이터" 폼과 함께 사용하는 것입니다. 이렇게 변경하면 변수가 항상 새로운 상태를 읽어들일 수 있게 됩니다. 하지만 이렇게 해도 새로운 props를 읽을 때는 다른 방법이 필요합니다.
또 다른 해결법은 useReducer()
를 사용하는 것입니다. 이 방법은 더 많은 유연성을 제공합니다. 리듀서 내부에서, 현재 상태와 새로운 props 두가지 모두에 대한 접근권한을 가집니다. dispatch
함수 자체는 변하지 않아서 어떠한 클로저로부터도 데이터를 주입시킬 수 있습니다. useReducer()
를 사용하는 것의 한계점 하나는 아직 side-effect를 emit 할 수 없다는 것입니다.
왜 이렇게 복잡해지는 걸까요?
임피던스 불일치라는 용어는 가끔 쓰이는 용어입니다. Phill Haack은 이 용어를 이렇게 설명했습니다.
어떤 이는 데이터베이스는 화성에서 오고 오브젝트는 금성에서 왔다고 할 것입니다. 데이터베이스는 오브젝트 모델을 자연스럽게 맵핑하지 못합니다. 이와 같은 일은 두 자석의 N극을 함께 밀어버리는 것과 같습니다.
"임피던스 불일치"는 데이터베이스와 오브젝트 사이에 있는 것이 아니고 리액트 프로그래밍 모델과 명령형 setInterval
API사이에 있습니다.
리액트 컴포넌트는 마운트되고 한동안 많은 상태변화를 겪을 것이지만 렌더링 결과는 모든 상태를 한 번에 표현합니다.
// Describes every render
return <h1>{count}</h1>
훅스는 이펙트로의 선언적인 접근을 적용할 수 있게 해줍니다.
// Describes every interval state
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
우리는 Interval을 세팅하지 않지만, 어떠한 딜레이를 갖고 어떻게 세팅될지에 대한 명세를 작성합니다. 훅스는 이러한 것들을 가능하게 합니다. 별개의 용어들로 지속적인 프로세스가 설명됩니다.
반대로, setInterval
은 프로세스를 적절하게 기술하지 못합니다. 일단 Interval을 설정하면, Interval을 없애는 것 말고는 어떠한 것도 변경할 수 없습니다.
이러한 점이 리액트 모델과 setInterval
API 사이의 부조화입니다.
리액트 컴포넌트의 Props와 state는 변할 수 있습니다. 변할 때 리액트는 지난 렌더링 상태에 대한 모든 것들을 지워버리며 다시 렌더링 할 것입니다.
useEffect()
훅도 이전 렌더링에 대해서 잊어버리는 것은 마찬가지입니다. 지난 이펙트를 지워버리고 다음 이펙트를 설정합니다. 다음 이펙트는 새로운 props와 state를 거쳐 설정됩니다. 그래서 이전의 첫 시도가 간단한 예제에 대해서는 성공했습니다.
하지만 setInterval()
은 "잊지 않습니다." setInterval은 직접 교체해주기 전까지는 이전의 props와 state를 계속 참조할 것입니다. 시간을 재설정하기 전까지는 교체도 불가능합니다.
잠깐만, 혹시 시간을 교체하지 않아도 재설정할 수 있나요?
문제점은 다음과 같이 압축됐습니다.
callback1
을 가진 setInterval(callback1, delay)
를 수행할 것입니다.callback2
가 있습니다.callback
을 대체할 수 없습니다!만일 interval을 전혀 변경하지 않고, 대신 변경 가능한 최근의 interval callback을 가리키는 savedCallback
변수를 도입하면 어떻게 될까요?
솔루션은 다음과 같습니다.
setInterval(fn, delay)
에서 함수가 savedCallback
을 호출하게 만들 것입니다. savedCallback
을 callback1
로 설정합니다.savedCallback
을 callback2
로 설정합니다.이 변경 가능한 savedCallback
은 재렌더링하는 동안에도 잘 보호되어야 합니다. 알다시피 일반적인 변수로는 그렇게 할 수 없습니다. 인스턴스 필드와 같은 것이 필요합니다.
HOOKS FAQ에서 그러한 역할을 하는 녀석을 찾을 수 있는데, 그게 바로 useRef()
입니다.
const savedCallback = useRef();
// { current: null }
(아마 리액트의 DOM refs는 친숙했을 것입니다. 훅스는 변할 수 있는 값을 갖기 위해 같은 개념을 사용합니다. ref는 어떤 것이든 넣을 수 있는 "박스"같은 것입니다.)
useRef()
는 렌더 사이에서 공유되는 변환 가능한 current
프로퍼티를 가지는 순수한 오브젝트를 반환합니다. 최근의 callback을 여기에 저장해놓을 수 있습니다.
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;
});
그리고 interval내에서 다음과 같은 방식으로 읽고 호출할 수 있습니다.
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
[]
덕분에 이펙트가 다시 실행되지 않습니다. 그리고 interval이 리셋되지 않습니다. 하지만, savedCallback
ref 덕분에, 항상 최근 렌더링 이후에 세팅한 callback을 읽을 수 있고 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>;
}
명백히, 위의 코드는 혼란을 야기할 수도 있습니다. 정 반대의 패러다임을 섞는 것은 정신착란을 일으킵니다. 변화 가능한 refs로 난장판을 만들어낼 수도 있습니다.
훅스는 클래스보다 더 낮은 수준의 원시성을 제공한다고 생각합니다. 하지만, 아름다운 점은 훅스가 우리가 조합을 통해 더 나은 선언적 추상을 만들 수 있게 해준다는 것입니다.
이상적으로, 아래와 같은 코드를 작성하길 원합니다.
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
커스텀 훅에 ref 매커니즘을 적용한 코드를 붙여넣습니다.
function useInterval(callback) {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
}
현재 1000
이라는 딜레이가 하드코딩되어있습니다. 이 값을 인자로 만들고 싶습니다.
function useInterval(callback, delay) {
interval을 설정할 때, 이 값을 이용하고 싶습니다.
let id = setInterval(tick, delay);
이제 delay
가 렌더 사이에 변화할 수 있습니다. 이 코드를 interval 이펙트의 디펜던시 내부에서 선언할 필요가 있습니다.
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
잠깐, interval effect를 재설정하는 것을 피하고 싶었지 않나요? 그래서 그것을 피하기 위해 명시적으로 []
를 넘겨줬었죠. 사실 완전히 그렇지는 않습니다. 오직 callback이 변할 때만 interval effect를 재설정하는 것을 피하고 싶어했죠. 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]);
}
잘 작동하는군요! 이제는 어떤 컴포넌트에서든 useInterval()
을 사용 할 수 있습니다. 그리고 구현의 디테일에 대해서는 너무 많이 생각하지 않아도 됩니다.
null
을 delay에 전달하는 것으로 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]);
이게 끝입니다. 이 코드는 모든 가능한 변화를 다룰 수 있습니다: 딜레이의 변화, 일시정지, 또는 interval의 재시작도요. useEffect()
API는 setup과 cleanup을 기술하기 위해 우리에게 더 많은 선행 노력을 요구합니다. 하지만, 새로운 케이스를 추가하는 것은 쉽습니다.
useInterval()
훅은 가지고 놀기 좋습니다. 사이드 이펙트가 선언적일 때, 복잡한 동작과 함께 조합하기 훨씬 쉽습니다.
이를테면, 다른 요소에 의해 컨트롤되는 하나의 interval delay
를 가질 수 있습니다.
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>
</>
);
}
훅스는 익숙해지기 위해서 약간의 노력을 요구합니다. 특히 명령형 코드와 선언형 코드의 경계선에서 그렇습니다. React Spring과 같은 강력한 선언형 추상화를 만들어낼 수 있습니다. 하지만 가끔 이러한 작업들은 짜증을 유발할 수도 있습니다.
현재 Hooks는 새로나온 상태입니다. 훅스를 이용한 여러가지 패턴이 검증되고 비교되어야 합니다. "베스트 프렉티스"라고 불리는 것을 따르는데 익숙하다면, 훅스를 적용하려고 너무 서두르지 마세요. 아직 시도하고 발견해야 할 것들이 많습니다.
이 포스팅이 Hooks와 함께 setInterval()
과 같은 API들을 사용하는데 빠질 수 있는 함정들에 대한 이해를 돕고 그것을 극복하는 것들을 도왔으면 좋겠습니다. 이 패턴들을 참고하여 더욱 선언적인 API가 그 위에 만들어졌으면 좋겠습니다.
좋은정보 감사드립니당^^