article

(번역) Everything you need to know about Concurrent React (with a little bit of Suspense)

kemut 2023. 3. 5. 01:13

 

원문 링크 : https://blog.codeminer42.com/everything-you-need-to-know-about-concurrent-react-with-a-little-bit-of-suspense/#synchronous-rendering

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

  1. ReactDOM.createRoot에 의해 <App/>에서 처음으로 렌더링
  2. 우선순위가 낮은 업데이트인 delayedFilter state 생성
  3. 사용자가 입력과 상호 작용할 때, 값을 수정하는 높은 우선순위 업데이트와 지연된 필터를 수정하는 낮은 우선순위 업데이트
    • 낮은 우선 순위 : startTransition 내부의 setDelayedFilter 호출을 트리거

input에 ‘t’를 입력했을 때 일어나는 일

  1. 첫 번째 키 입력 후에 우선 순위가 높은 렌더가 시작
    • filter는 값 ‘t’를 갖는다. 우선순위가 낮은 delayedFilter는 값 “”으로 변경되지 않음
    • delayedFilter가 ‘t’로 렌더링 되기 전까지, <List />는 이전 것이다.
    • delayFilter가 수정되지 않는 것은 input이 빠른 응답성을 유지하는데 중요한 요소이며, 이는 <List/>가 메모되고 이전 렌더와 동일한 delayFilter(아직 바뀌지 않은)를 수신하기 때문이다.
  2. 우선순위가 높은 렌더링이 완료되면 VDOM에 커밋되고, 라이프사이클을 계속 진행한다.
    • insertion effects → modifies DOM → layout → layout effects → paint → effects
  3. 우선순위가 낮은 렌더링을 시작한다.
    • filter는 값이 “t”, delayedFilter도 값이 “t”가 된다.
  4. 낮은 우선순위 렌더링 중에 다음 문자인 "a"를 입력하면 높은 우선순위와 낮은 우선순위의 두 가지 업데이트가 발생한다.
    • 우선 순위가 높은 업데이트가 트리거되었기 때문에, 우선 순위가 낮은 렌더링이 중단된 다음 우선 순위가 높은 렌더링을 시작합니다.
    • filter는 “ta”를 가지고, delayedFilter는 “”를 가지는 상황이 발생 할 수도 있다.
    • delayedFilter는 여전히 이전과 같은 값을 가지고 있으며, 속도가 느린 <List />는 렌더링 되지 않는다.
  5. 우선 순위가 높은 렌더가 끝나면 우선 순위가 낮은 렌더로 돌아가고 입력을 중지할 때까지 주기가 계속된다.

 

 

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>
  );
});
  1. 응용 프로그램이 처음 렌더링 될 때
    • 항상 높은 우선순위의 렌더링을 트리거한다.
    • 첫 번째 렌더링에서 filter와 deleratedFilter는 같은 값을 갖는다.
  2. useDeleredValue가 처음 호출될 때
    • 초기화된 값만 반환한다.
    • 낮은 우선 순위의 업데이트는 트리거하지 않는다.
  3. 첫번째 상호작용을 하면 (input에 ‘t’를 입력하면)
    • setFilter가 호출되어 우선순위가 높은 업데이트가 트리거된다.
    • 첫 번째 높은 우선순위 렌더링에서 filter는 값이 ‘t’ 이므로, useDeleredValue는 ‘t’로 호출되자만, 해당하는 낮은 우선순위의 렌더링이 완료될 때까지 이전값 “” 를 계속 반환한다.
  4. 첫 번째 높은 우선순위 렌더링이 완료되면
    • DeferredValue를 사용하기 위해 다른 값을 전달했기 때문에 트리거된 낮은 우선순위 렌더링이 시작된다.

자세한 동작

  • filter가 “t”여도, <List/>가 렌더링 되기 전까지 deffredFilter는 “”인 부분들을 볼 수 있다.
  • 낮은 우선순위 업데이트 중에 DeferredValue를 사용하도록 새 값은 전달해도 다른 업데이트가 트리거되지 않는다.
    • 새 값을 수신하더라도 이 새로 수신된 값은 hook을 "통과"하여 즉시 반환된다.
  • 우선 순위가 높은 렌더 ←→ 우선 순위가 낮은 렌더 중에, DeferredValue를 사용하기 위해 서로 다른 값을 전달하도라도 모든것이 거의 동일하게 유지된다.
    • 낮은 우선순위 렌더 중에 수신되는 값이 중요
  • 서로 다른 DeleredValue업데이트로 인한 호출은 모두 단일 렌더에서 일괄 처리된다.
  • 구성 요소 트리의 관련 없는 부분에서 DeferredValue를 사용하라는 호출이 여러 번 있더라도 업데이트가 모두 일괄 처리되고 우선 순위가 낮은 단일 렌더링으로 해결된다.