article
(번역) Everything you need to know about Concurrent React (with a little bit of Suspense)
kemut
2023. 3. 5. 01:13
Intro
- UI는 다양한 부분들로 구성되며, 각 부분들은 다른 속도로 상호작용에 응답한다.
- input에 무언가를 입력하는 것과 같은 간단한 부분은 상호작용에 즉각적으로 반응할 수 있으나, 필터링과 같이 오래 걸리는 부분은 반응하는데 오래 걸릴 수 있다.
- synchronous rendering (동기식 렌더링)은 오래 걸리는 응답이 다른 응답을 차단할 수 있다.
- concurrent renderer 는 오래 걸리는 응답을 실행하는 동안, 다른 응답을 차단하지 않도록 한다.
- concurrent renderer 를 수행하면 응용프로그램이 실제로 더 빨라지지는 않지만, UI 응답성을 높일 수 있다.
- concurrent renderer는 백그라운드에서 느린 컴포넌트를 렌더링 할 수 있도록 하여, 빠른 컴포넌트와 느린 컴포넌트가 고유한 속도로 사용자 상호작용에 응답할 수 있도록 한다.
- 느린 부분이 백그라운드에서 렌더링 될 때, 빠른 컴포넌트를 업데이트 해야 될 때마다, 느린 부분의 렌더링을 중단하여 빠른 부분의 응답성을 유지한 다음 빠른 부분의 렌더링이 끝나면 느린 부분의 렌더링으로 돌아간다.
Problem
클라이언트에서 목록을 필터링하고, 그 목록을 렌더링한다고 가정해보자. 이 설정에서는 [목록, input] 두 가지 주요 UI 요소가 있다.
데모 링크 : https://stackblitz.com/edit/react-xsgxkg?file=src/App.js
- list를 필터링 할 때, sleep함수로 인위적으로 응답을 지연시킨다.
- 지연되는 만큼 다른 동작은 할 수 없다.
❓ 지연되는 응답이 다른 응답을 차단하지 않을 방법은 없을까?
Synchronous Rendering
- concurrent features가 없다면, React는 컴포넌트를 동기적으로 렌더링한다.⇒ 렌더링이 완료된 후에만 다른 작업을 수행한다.
- ⇒ 렌더링을 시작하면 (예외를 제외하고는) 아무것도 렌더링을 중단할 수 없다.
- 렌더가 얼마나 걸리든 간에, 해당 렌더 중에 발생하는 새 event는 이전 event가 종료된 후에만 처리된다.
데모 링크 : https://stackblitz.com/edit/react-slj4mv?file=src%2FApp.js
- Render Slow 버튼은 렌더링 하는데 2초가 걸리는 버튼이다.
- Render Slow 버튼 클릭 → input 입력하면 2초간 동작하지 않는다.
Solution
💡 React18은 이 문제를 해결하기 위해 concurrent rendering을 도입했다. 이제 렌더링은 2가지 종류로 나뉜다.
- High Priority (Urgent) Updates
- Low Priority (Non-Urgent) Updates
High Priority (Urgent) Updates
높은 우선순위 업데이트 ( 긴급 업데이트 )
- ReactDOM.createRoot의 첫번째 렌더링
- setState 호출
- Reducer의 디스패치 사용
- SyncExternalStore(업데이트에 가입된 스토어의 슬라이스)
Low Priority (Non-Urgent) Updates
낮은 우선 순위 업데이트
- DeferredValue
- startTransition
동작 방식
- Low Priority는 High Priority 렌더가 완료된 후에만 실행되기 시작한다.
- High Priority Updates로 인해 중단될 수 있다.
- Low Priority 렌더링이 중단되면, High Priority 렌더링이 완료될 때까지 기다렸다가 처음부터 다시 시작한다.
Concurrent List Filtering
- 입력(빠른 응답)을 필터링 목록(느린 응답)에서 분리하고 concurrent기능을 사용하고 있다.
데모 링크 : https://stackblitz.com/edit/react-gqqvon?file=src%2FApp.js
- ReactDOM.createRoot에 의해 <App/>에서 처음으로 렌더링
- 우선순위가 낮은 업데이트인 delayedFilter state 생성
- 사용자가 입력과 상호 작용할 때, 값을 수정하는 높은 우선순위 업데이트와 지연된 필터를 수정하는 낮은 우선순위 업데이트
- 낮은 우선 순위 : startTransition 내부의 setDelayedFilter 호출을 트리거
input에 ‘t’를 입력했을 때 일어나는 일
- 첫 번째 키 입력 후에 우선 순위가 높은 렌더가 시작
- filter는 값 ‘t’를 갖는다. 우선순위가 낮은 delayedFilter는 값 “”으로 변경되지 않음
- delayedFilter가 ‘t’로 렌더링 되기 전까지, <List />는 이전 것이다.
- delayFilter가 수정되지 않는 것은 input이 빠른 응답성을 유지하는데 중요한 요소이며, 이는 <List/>가 메모되고 이전 렌더와 동일한 delayFilter(아직 바뀌지 않은)를 수신하기 때문이다.
- 우선순위가 높은 렌더링이 완료되면 VDOM에 커밋되고, 라이프사이클을 계속 진행한다.
- insertion effects → modifies DOM → layout → layout effects → paint → effects
- 우선순위가 낮은 렌더링을 시작한다.
- filter는 값이 “t”, delayedFilter도 값이 “t”가 된다.
- 낮은 우선순위 렌더링 중에 다음 문자인 "a"를 입력하면 높은 우선순위와 낮은 우선순위의 두 가지 업데이트가 발생한다.
- 우선 순위가 높은 업데이트가 트리거되었기 때문에, 우선 순위가 낮은 렌더링이 중단된 다음 우선 순위가 높은 렌더링을 시작합니다.
- filter는 “ta”를 가지고, delayedFilter는 “”를 가지는 상황이 발생 할 수도 있다.
- delayedFilter는 여전히 이전과 같은 값을 가지고 있으며, 속도가 느린 <List />는 렌더링 되지 않는다.
- 우선 순위가 높은 렌더가 끝나면 우선 순위가 낮은 렌더로 돌아가고 입력을 중지할 때까지 주기가 계속된다.
concurrent feature를 활성화 하는 방법
💡 아래 기능들은 특정 업데이트를 낮은 우선순위로 표시한다.
useTranstion ( > startTransition )
https://beta.reactjs.org/reference/react/useTransition
- useTranstion은 startTransition 함수 내에서 발생하는 모든 업데이트가 낮은 우선순위 업데이트로 표시한다.
- 일부 상태 업데이트를 transition으로 표시하려면 컴포넌트의 최상위 수준에서 useTransition을 사용해야 한다.
- startTransition은 항상 높은 우선 순위와 낮은 우선 순위 업데이트를 모두 트리거한다.
- startTransition을 호출하면 내부 업데이트가 트리거되지 않더라도 높은 우선순위 업데이트 ⇒ 낮은 우선순위 업데이트가 트리거된다.
export default function App() {
// 높은 우선순위
const [filter, setFilter] = useState("");
// 낮은 우선순위가 된 state
const [delayedFilter, setDelayedFilter] = useState("");
const [isPending, startTransition] = useTransition();
// 디버깅 하기 위한 훅 ( 무시해도 됩니다! )
useDebug({ filter, delayedFilter });
return (
<div className="container">
<input
value={filter}
onChange={(e) => {
setFilter(e.target.value);
startTransition(() => {
// delayedFilter를 변경시켜 낮은 우선순위를 실행시킨다.
setDelayedFilter(e.target.value);
});
}}
/>
{isPending && "Recalculating..."}
<List filter={delayedFilter} />
</div>
);
}
// 렌더링이 오래 걸리는 컴포넌트 ( mome를 해두어서, filter가 같다면 이전 결과를 재사용합니다. )
const List = memo(({ filter }) => {
const filteredList = list.filter((entry) =>
entry.name.toLowerCase().includes(filter.toLowerCase())
);
sleep(100);
return (
<ul>
{filteredList.map((item) => (
<li key={item.id}>
{item.name} - ${item.price}
</li>
))}
</ul>
);
});
startTransition's callback runs IMMEDIATELY
- transtion을 시작하기 위해 인수로 전달하는 함수는 즉시 동기적으로 실행된다.
- 이는 디버깅에는 적합하지만, 콜백 내부에서 값비싼 작업을 수행하지 않아야 한다는 것을 나타낸다.
- 그렇지 않으면 렌더를 차단할 수 있다.
- 업데이트가 트리거되는 것은 괜찮지만 비용이 많이 드는 작업이 콜백 자체 내에서 수행되는것은 괜찮지 않다.
State updates inside startTransition's callback MUST be in the same call stack as the callback itself
- 상태 업데이트가 낮은 우선순위로 표시되려면, startTransition의 콜백 내에서 동일한 콜 스택에 호출되어야 한다.
아래처럼 작성한다면, 제대로 작동하지 않는다.
startTransition(() => {
setTimeout(() => {
// By the time setTimeout's callback is called
// we're already in another call stack
// This will be marked as a high priority update
// instead
setCount((count) => count + 1);
}, 1000);
});
startTransition(async () => {
await asyncWork();
// Here we're inside a different call stack
setCount((count) => count + 1);
});
- setTimeout의 콜백이 호출될 때까지 이미 다른 콜 스택에 있다. 이것은 우선 순위가 높은 업데이트로 표시된다.
위 동작을 구현하고 싶다면, 이렇게 작성해야 한다.
setTimeout(() => {
startTransition(() => {
setCount((count) => count + 1);
});
}, 1000);
await asyncWork();
startTransition(() => {
setCount((count) => count + 1);
});
ALL transitions are batched in a SINGLE rerender
- 모든 transtion은 단일 렌더에 batch 됩니다.
- 낮은 우선순위 업데이트가 보류 중일 경우
- 첫 번째 낮은 우선 순위 업데이트를 처리하기 전에 다른 낮은 우선 순위 업데이트가 트리거 되면
- ⇒ 모든 낮은 우선 순위 업데이트가 동일한 렌더에서 한 번에 수행된다.
- 현재 우선 순위가 낮은 업데이트가 완전히 관련이 없는 구성 요소 트리의 일부를 렌더링하고 한 부분이 이미 렌더링을 마쳤더라도, 중단되면 렌더링이 처음부터 시작된다.
transtion은 ref에는 낮은 우선순위로 작동하지 않는다.
- ref를 포함하여 startTransition의 콜백 내에서 거의 모든 작업을 수행할 수 있지만, 업데이트를 낮은 우선순위로 표시하는 유일한 방법은 setState를 호출하는 것이다.
Deferred Values
- 명령적으로 작동하는 Transitions API 와 달리, DeferredValue works를 선언적 방식으로 사용한다.
- useDeleredValue는 낮은 우선 순위 업데이트의 결과인 상태를 반환하고 인수로 전달하는 값으로 설정된다.
- DeferredValue에 전달된 현재 값이 이전에 수신한 값과 다를 때 낮은 우선순위 업데이트가 트리거된다.
export default function App() {
// 높은 우선순위 업데이트
const [filter, setFilter] = useState("");
// 이 상태는 낮은 우선순위 업데이트에 의해 업데이트 된다.
const deferredFilter = useDeferredValue(filter);
useDebug({ filter, deferredFilter });
return (
<div className="container">
<input
value={filter}
onChange={(e) => {
setFilter(e.target.value);**
}}
/>
<List filter={deferredFilter} />
</div>
);
}
const List = memo(({ filter }) => {
const filteredList = list.filter((entry) =>
entry.name.toLowerCase().includes(filter.toLowerCase())
);
sleep(100);
return (
<ul>
{filteredList.map((item) => (
<li key={item.id}>
{item.name} - ${item.price}
</li>
))}
</ul>
);
});
- 응용 프로그램이 처음 렌더링 될 때
- 항상 높은 우선순위의 렌더링을 트리거한다.
- 첫 번째 렌더링에서 filter와 deleratedFilter는 같은 값을 갖는다.
- useDeleredValue가 처음 호출될 때
- 초기화된 값만 반환한다.
- 낮은 우선 순위의 업데이트는 트리거하지 않는다.
- 첫번째 상호작용을 하면 (input에 ‘t’를 입력하면)
- setFilter가 호출되어 우선순위가 높은 업데이트가 트리거된다.
- 첫 번째 높은 우선순위 렌더링에서 filter는 값이 ‘t’ 이므로, useDeleredValue는 ‘t’로 호출되자만, 해당하는 낮은 우선순위의 렌더링이 완료될 때까지 이전값 “” 를 계속 반환한다.
- 첫 번째 높은 우선순위 렌더링이 완료되면
- DeferredValue를 사용하기 위해 다른 값을 전달했기 때문에 트리거된 낮은 우선순위 렌더링이 시작된다.
자세한 동작
- filter가 “t”여도, <List/>가 렌더링 되기 전까지 deffredFilter는 “”인 부분들을 볼 수 있다.
- 낮은 우선순위 업데이트 중에 DeferredValue를 사용하도록 새 값은 전달해도 다른 업데이트가 트리거되지 않는다.
- 새 값을 수신하더라도 이 새로 수신된 값은 hook을 "통과"하여 즉시 반환된다.
- 우선 순위가 높은 렌더 ←→ 우선 순위가 낮은 렌더 중에, DeferredValue를 사용하기 위해 서로 다른 값을 전달하도라도 모든것이 거의 동일하게 유지된다.
- 낮은 우선순위 렌더 중에 수신되는 값이 중요
- 서로 다른 DeleredValue업데이트로 인한 호출은 모두 단일 렌더에서 일괄 처리된다.
- 구성 요소 트리의 관련 없는 부분에서 DeferredValue를 사용하라는 호출이 여러 번 있더라도 업데이트가 모두 일괄 처리되고 우선 순위가 낮은 단일 렌더링으로 해결된다.