UI의 일부 업데이트를 지연시킬 수 있는 React Hook
const deferredValue = useDeferredValue(value)
컴포넌트의 최상위 수준에서 useDeferredValue를 호출하여 해당 값의 지연된 버전을 가져올 수 있음:
import { useState, useDeferredValue } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// ...
}
value: 지연하려는 값. 모든 타입을 가질 수 있음.초기 렌더링 중에 반환되는 지연된 값은 사용자가 제공한 값과 동일함. 업데이트하는 동안 React는 먼저 이전 값으로 리렌더링을 시도하고(따라서 이전 값을 반환함), 새로운 값으로 백그라운드에서 리렌더링을 시도함(따라서 업데이트된 값을 반환함).
useDeferredValue에 전달하는 값은 문자열과 숫자 같은 원시값이거나 렌더링 외부에서 생성된 객체여야함. 렌더링 중에 새 객체를 생성하고 즉시 useDeferredValue에 전달하면 렌더링할 때마다 값이 달라져 불필요한 백그라운드 리렌더링이 발생할 수 있음.
useDeferredValue가 다른 값(Object.is로 비교)을 받으면, 현재 렌더링(여전히 이전 값을 사용)에 더해서 새 값으로 백그라운드에서 리렌더링하도록 예약함. 백그라운드 리렌더링은 중단할 수 있음(interruptible). value에 대한 다른 업데이트가 있으면 React는 백그라운드 리렌더링을 처음부터 다시 시작함. 예를 들어, 지연된 값을 받는 chart가 리렌더링되는 속도보다 사용자가 더 빠르게 입력값을 타이핑하는 경우, chart는 사용자가 타이핑을 멈춘 후에만 다시 렌더링됨
useDeferredValue는 <Suspense>와 통합됨. 새 값으로 인한 백그라운드 업데이트로 인해 UI가 일시 중단되어도 사용자에게 fallback이 표시되지 않음. 데이터가 로드될 때까지 이전 지연된 값이 표시됨.
useDeferredValue는 그 자체로 추가 네트워크 요청을 방지하지 않음.
useDeferredValue 자체로 인한 고정된 지연은 없음. React는 원래의 리렌더링을 완료하자마자, 새로운 지연된 값으로 백그라운드 리렌더링 작업을 즉시 시작함. 타이핑과 같은 이벤트로 인한 모든 업데이트는 백그라운드 리렌더링을 중단하고 우선순위를 부여받음.
useDeferredValue으로 인한 백그라운드 리렌더는 화면에 커밋될 때까지 Effects를 실행하지 않음. 백그라운드 리렌더링이 일시 중단되면 데이터가 로드되고 UI가 업데이트된 후에 해당 Effects가 실행됨.
컴포넌트의 최상위 레벨에서 useDeferredValue를 호출하여 UI의 일부 업데이트를 지연할 수 있음:
import { useState, useDeferredValue } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// ...
}
초기 렌더링 중에 지연된 값은 사용자가 제공한 값과 동일함.
업데이트하는 동안 지연된 값은 최신 값보다 "뒤처지게" 됨. 특히 React는 지연된 값을 업데이트하지 않고 리렌더링한 다음, 새로 받은 값으로 백그라운드에서 리렌더링을 시도함.
이 기능이 유용한 경우는?
Note
이 예에서는 Suspense를 지원하는 데이터 소스을 사용한다고 가정함:
참고: Suspense와 그 한계
다음 예에서는 검색 결과를 가져오는 동안 SearchResults 컴포넌트가 일시 중단됨. "a"를 입력하고 결과를 기다린 다음 "ab"로 수정하면, "a"에 대한 결과는 loading fallback으로 대체됨.
import { Suspense, useState } from 'react';
import SearchResults from './SearchResults.js';
export default function App() {
const [query, setQuery] = useState('');
return (
<>
<label>
Search albums:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={query} />
</Suspense>
</>
);
}
이를 대체할 수 있는 일반적인 UI 패턴은 결과 목록 업데이트를 지연하고 새 결과가 준비될 때까지 이전 결과를 계속 표시하는 것. useDeferredValue를 호출하여 query의 지연된 버전을 전달하면 됨:
import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';
export default function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<label>
Search albums:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={deferredQuery} />
</Suspense>
</>
);
}
query는 즉시 업데이트되므로 input에 새 값이 표시되지만, 데이터가 로드될 때까지 deferredQuery는 이전 값을 유지하므로 SearchResults는 잠시 동안 이전 결과를 표시함.
"a"를 입력하고 결과가 로드될 때까지 기다린 다음 입력을 "ab"로 수정하면, 이제 Suspense fallback 대신 새 결과가 로드될 때까지 이전 결과 목록이 표시되는 것을 볼 수 있음.
DEEP DIVE: How does deferring a value work under the hood?
두 단계로 나누어 생각할 수 있음:
먼저, React는 새로운
query("ab")로 리렌더링하지만, 이전deferredQuery(여전히"a")를 사용함. 결과 목록에 전달하는deferredQuery값은 지연되어query값보다 "뒤처지게" 됨.백그라운드에서 React는
query와deferredQuery를 모두"ab"로 업데이트하여 리렌더링하려고 시도함. 이 리렌더링이 완료되면 React는 이를 화면에 표시함. 그러나 일시 중단되면("ab"에 대한 결과가 아직 로드되지 않은 경우) React는 이 렌더링 시도를 포기하고 데이터가 로드된 후 이 리렌더링을 다시 시도함. 사용자는 데이터가 준비될 때까지 계속 지연된 이전 값을 보게 됨.지연된 '백그라운드' 렌더링은 중단할 수 있음(interruptible). 예를 들어, 사용자가 input에 다시 타이핑하면 React는 진행중이던 백그라운드 렌더링을 포기하고 새 값으로 다시 백그라운드 렌더링을 시작함. React는 항상 제공받은 최신 값을 사용함.
키 입력마다 여전히 네트워크 요청이 있다는 점에 유의할 것. 여기서 지연되는 것은 네트워크 요청 자체가 아니라 결과가 준비될 때까지 결과를 표시하는 것. 사용자가 계속 타이핑하더라도 각 키 입력에 대한 응답은 캐시되므로 백스페이스 키를 누르더라도 데이터를 다시 가져오지 않음.
위의 예에서는 최신 쿼리에 대한 결과 목록이 아직 로드 중이라는 표시가 없음. 새 결과를 로드하는 데 시간이 오래 걸리는 경우 사용자가 혼란스러워할 수 있음. 결과 목록이 최신 쿼리와 일치하지 않는다는 것을 사용자에게 더 명확하게 알리기 위해 이전 결과 목록이 표시되는 동안 visual indication을 추가할 수 있음:
<div style={{
opacity: query !== deferredQuery ? 0.5 : 1,
}}>
<SearchResults query={deferredQuery} />
</div>
이렇게 하면 타이핑을 시작하자마자 새 결과 목록이 로드될 때까지 이전 결과 목록이 약간 어두워짐. 아래 예시처럼 CSS transition을 추가하여 흐리게 표시되는 시간을 지연시켜 점진적으로 느껴지도록 할 수도 있음:
import { Suspense, useState, useDeferredValue } from 'react';
import SearchResults from './SearchResults.js';
export default function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<label>
Search albums:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<div style={{
opacity: isStale ? 0.5 : 1,
transition: isStale ? 'opacity 0.2s 0.2s linear' : 'opacity 0s 0s linear'
}}>
<SearchResults query={deferredQuery} />
</div>
</Suspense>
</>
);
}
성능 최적화를 위해 useDeferredValue를 적용할 수도 있음. UI의 일부가 리렌더링되는 속도가 느리고, 이를 최적화할 쉬운 방법이 없으며, 이것이 나머지 UI를 blocking하지 않도록 하려는 경우에 유용함.
키 입력 시마다 다시 렌더링되는 text field와, chart 또는 긴 목록과 같은 컴포넌트가 있다고 가정하면:
function App() {
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={text} />
</>
);
}
먼저, props가 동일한 경우 리렌더링하지 않도록 SlowList를 memo로 감싸서 optimize함:
const SlowList = memo(function SlowList({ text }) {
// ...
});
하지만 이 방법은 이전 렌더링 때와 SlowList의 props가 동일한 경우에만 도움이 됨. 지금 직면하고 있는 문제는 이전 렌더링과 현재 렌더링의 props가 서로 다를 때, 그리고 실제로 다른 시각적 출력을 표시해야 할 때 속도가 느리다는 것.
구체적으로, 주요한 성능 문제는 사용자가 input에 타이핑할 때마다 SlowList가 새로운 props를 받아서 전체 트리를 리렌더링하기 때문에 타이핑이 끊기는 느낌이 든다는 것. 이 경우, useDeferredValue를 사용하면 결과 목록 업데이트(느려도 됨)보다 입력 업데이트(빨라야 함)의 우선순위를 높일 수 있음:
function App() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}
이렇게 한다고 해서 SlowList의 리렌더링 속도가 빨라지지는 않음. 하지만 키 입력을 차단하지 않도록 목록 리렌더링의 우선순위를 낮출 수 있다는 것을 React에 알려줌. 목록은 input보다 "지연"되었다가 input을 "따라잡음". 이전과 마찬가지로 React는 가능한 한 빨리 목록을 업데이트하려고 시도하지만 사용자가 입력하는 것을 차단하지는 않음.
Pitfall
이 최적화를 위해서는
SlowList를memo로 감싸는 것이 필요함.text가 변경될 때마다 React가 부모 컴포넌트를 빠르게 리렌더링할 수 있어야 하기 때문. 리렌더링하는 동안deferredText는 여전히 이전 값을 가지므로SlowList는 리렌더링을 건너뛸 수 있음(props가 변경되지 않았기 때문).memo가 없다면 어쨌든 리렌더링해야 하므로 최적화의 의미가 무색해짐.
DEEP DIVE: How is deferring a value different from debouncing and throttling?
이 시나리오에서 이전에 사용했을 수 있는 두 가지 일반적인 최적화 기술:
- Debouncing은 사용자가 입력을 멈출 때까지(예: 1초 동안) 기다렸다가 목록을 업데이트하는 것
- Throttling은 목록을 가끔씩(예: 최대 1초에 한 번) 업데이트하는 것
이러한 기법들은 경우에 따라 유용하지만, 렌더링을 최적화하는 데는 React 자체와 긴밀하게 통합되어 있고 사용자의 기기에 맞게 조정되는
useDeferredValue가 더 적합함.Debouncing이나 throttling과 달리,
useDeferredValue는 고정 지연을 선택할 필요가 없음. 사용자의 디바이스가 빠른 경우(예: 고성능 노트북), 지연된 리렌더링은 거의 즉시 발생하며 눈에 띄지 않을 것. 사용자의 디바이스가 느리면 디바이스가 느린 정도에 비례하여 목록이 input보다 '뒤쳐짐'.또한 debouncing이나 throttling과 달리,
useDeferredValue로 수행되는 지연된 리렌더링은 기본적으로 중단할 수 있음. 즉, React가 큰 목록을 리렌더링하는 도중에 사용자가 다른 키를 입력하면 React는 해당 리렌더링을 중단하고 키 입력을 처리한 다음 백그라운드에서 리렌더링을 시작함. 반면, debouncing과 throttling은 렌더링이 키 입력을 차단하는 순간을 지연할 뿐이므로 여전히 불규칙한 경험을 제공함.최적화하려는 작업이 렌더링 중에 발생하지 않는 경우에도 debouncing과 throttling은 여전히 유용함. 예를 들어, debouncing과 throttling을 사용하면 네트워크 요청을 더 적게 실행할 수 있음. 이러한 기술을 함께 사용할 수도 있음.