훅을 보기전에 먼저 알면 좋은 개념들
여기서 Concurrent
는 asynchronous rendering
으로 React 18에서 등장한 비동기 렌더링 기능이다.
렌더링 도중 유저 인터렉션이 일어났을때 유연하게 렌더링 우선순위를 바꿔
사용자에게 최상의 인터렉션 경험을 제공할 수 있다.
React 18 은 fiber 라는 엔진을 개선하여 자체적인 스케줄러를 가지게 되었다. priority queue, 다중 버퍼링 등으로 렌더링을 스케줄링하는 알고리즘이 자체적으로 구현되어있다.
Concurrent Mode로 렌더링 하는 도중에 다른 작업으로 넘어갔을때
useDeferredValue
UI의 일부 업데이트를 지연시킬 수 있는 React 훅이다.
startTransition
UI를 block하지 않고 state를 업데이트 하는 react의 api이다.
등의 react에서 제공하는 concurrent feature가 있다. 이를 통해 웹 사용자의 심리를 사용해 렌더링 우선순위를 정할 수 있다.
의도치않게 동일한 상태에 대해 여러가지의 UI 형태가 나타나는 것
을 tearing
이라고 한다.
자바스크립트는 단일 스레드이기 때문에 일반적으로 웹개발에서는 이 문제가 발생하지 않았지만, React 18 이후부터 concurrent rendering 때문에 이 문제가 등장한다. 즉 StartTransition이나 Suspense와 같은 concurrent feature을 사용할때 렌더링 도중 우선순위가 높은 UI 변경에게 스레드를 위임하기 때문에 이러한 문제가 일어날 수 있다.
External Store를 한정으로 생각할 때
동기렌더링은 렌더링이 끝날때까지 external store값이 바뀌지 않고 렌더링이 끝난 이후에 업데이트가 가능하므로 UI가 일관적이다. 따라서 tearing이 일어나지 않는다.
비동기 렌더링에서 외부 스토어의 데이터가 'blue’라면 처음에 렌더링한 색상은 파란색이다. 렌더링 도중 React가 스토어를 업데이트하면 데이터는 'red'으로 업데이트된다. 이후, React는 업데이트된 값 'red'를 활용하여 렌더링을 계속한다다. 결과적으로 tearing이 일어난다.
위에서 external store 한정으로 생각한 이유
useState, useReducer, context, props
Internal store의 상태를 변경할때는 React가 즉각적으로 상태를 변경하지 않고 큐를 업데이트하고 렌더링을 스케줄한다. 렌더링을 시작하면 내부적인 알고리즘과 비교 연산을 통해 큐 전체에서 어떤 값을 참조할지 결정하고 이때문에 렌더링에서 tearing이 발생하지 않는다.
자체적으로 상태관리 툴을 만들어 리액트 훅과 연동시킨 상태관리 라이브러리들 Redux, mobs, zustand, jotai, recoil. 전역변수, 모듈범위 변수, DOM 상태
External store의 상태를 사용한다면 React가 알지 못하는 사이에 외부 데이터 저장소를 렌더링 중에 업데이트하기 때문에 React의 내부적인 알고리즘을 사용하지 못한다. 따라서 external store를 제공하는 라이브러리들은 concurrent feature를 자체적으로 대비하여야 한다.
외부 소스에서 데이터를 안전하고 효율적으로 읽는 훅.
외부 저장소 라이브러리의 tearing 문제를 해결하기 위해 useMutableSource 훅이 추가되었다.
useMutableSource and selector stability
useMutableSource → useSyncExternalStore
useMutableSource
는 내장된 selector API
를 제공하지 않기 때문에 selector
를 자체적으로 만들경우, 이 selector
가 memorization
되지 않은 상태라면 매번 값이 변경될때마다 store를 다시 구독해야 했다.
selector를 보통 컴포넌트 단위에서 inline으로 작성하기 때문에 컴포넌트가 리렌더링 될때마다 store도 매번 다시 구독하게 된다.
따라서 사용자가 작성한 모든 selector
함수가 안정적인 참조를 위해 useCallback
과 useMemo
로 감싸야했다.
selector : 스토어에 있는 여러 값들 중 필요한 값만 불러오거나 원본 상태 값은 그대로 두고 상태의 특정 값만 계산해서 사용하게 도와주는 함수. 이 함수는 해당 하위 집합이 변경된 경우에만 React가 다시 렌더링할 필요가 있다는 신호를 보낸다.
개발적으로 불편하기도 하고, 성능이슈도 발생하고 React 문서에서 useCallback과 useMemo는 성능 최적화를 위해서만 존재한다
고 강조하고 있기 때문에 의도된 목적과는 다른 사용으로 볼 수 있다.
useMutableSource → useSyncExternalStore
위 내용에 있는 내용인데 제가 이해를 못했어요... 어쨋든...
Concurrent Mode에서 startTransition으로 래핑되었더라도 가끔 UI의 일부가 fallback으로 대체되어 보인다는 문제가 발생했다.
처음에 생각한 지원전략 방식이 잘못되었다는 것을 인지하고, 결과적으로 useMutableSource 훅이 재설계되고 이름이 useSyncExternalStore로 변경되었다.
external store를 subscribe하는 리액트 훅.
외부 상태관리 라이브러리들이 tearing 문제에 대비하기 위해 추가된 훅으로 일반 사용자들이 사용할 일은 거의.. 없다. 그리고 React에서 가장 긴 훅임.
How useSyncExternalStore() works internally in React?
useSyncExternalStore는 내부적으로 두가지 이슈를 해결한다.
Tearing under concurrent mode
렌더링이 끝나고 commit이 되기전에, 일관성 체크를 스케줄링하면서 concurrent mode에서 리렌더링이 강제로 수행되므로 UI에 일관되지 않은 데이터가 그려지지 않게 한다.
undetected external store changes
변경사항이 있는지 확인하고, 변경된 경우 concurrent mode에서 다시 리렌더링하도록 스케줄링한다.
useSyncExternalStore - The underrated React API
useSyncExternalStore로 만들어보는 전역상태관리 라이브러리
React State Management Without Dependencies
앞으로의 React가 나아가는 방향의 큰 축이 동시성 렌더링인 만큼 갑자기 동시성 기능을 추가해야 할지도 모르니까 사용법을 알아두면 좋음.
그리고 영영 동시성을 적용하지 않는다고 하더라도 해당 훅을 사용하면 복잡한 상태값을 통째로 참조해서 발생하는 불필요한 리렌더링 문제도 해결할 수 있다. (원래는 useState와 useEffect를 써서 다소 복잡하게 구현할 수 있지만 useSyncExternalStore를 사용하면 간단하게 해결이 가능하다.)
즉, 외부 상태관리 라이브러리에서 tearing 해결을 하기 위한 목적이 아니라, 순수 react에서 전역 상태 관리를 쉽게 하려는 목적으로도 사용할 수 있다.
React-Router의 useLocation()은 굉장히 많은 attribute(pathname, hasn, search 등등) 을 가진 객체를 반환하지만 주로.. 이걸 모두 사용하지는 않는다. 하지만 이 훅을 호출하기만 하면 이러한 attribute중 하나라도 업데이트되면 렌더링이 트리거 된다.
여기서 React-Router는 v5를 사용한다.
function CurrentPathname() {
const { pathname } = useLocation();
return <div>{pathname}</div>;
}
function CurrentHash() {
const { hash } = useLocation();
return <div>{hash}</div>;
}
function Links() {
return (
<div>
<Link to="#link1">#link1</Link>
<Link to="#link2">#link2</Link>
<Link to="#link3">#link3</Link>
</div>
);
}
function App() {
return (
<div>
<CurrentPathname />
<CurrentHash />
<Links />
</div>
);
}
위의 코드에서 link를 클릭하면 hash
attribute를 사용하지 않아도 CurrentPathName 컴포넌트
는 리렌더링 된다.
hash : 주소의 # 문자열 뒤의 값
여기서 useSyncExternalStore를 사용하면 리렌더링을 막을 수 있다. (브라우저의 기록도 external store로 간주되기 때문에 사용 가능)
아래 3개의 값을 사용해서 간단하게 useHistorySelector
를 만들 수 있다.
function useHistorySelector(selector) {
const history = useHistory();
return useSyncExternalStore(history.listen, () =>
selector(history)
);
}
useSyncExternalStore의 parameter
useHistorySelector를 사용해서 컴포넌트를 수정하면
function CurrentPathname() {
const pathname = useHistorySelector(
(history) => history.location.pathname // pathname만 사용하는 selector
);
return <div>{pathname}</div>;
}
function CurrentHash() {
const hash = useHistorySelector(
(history) => history.location.hash // hash만 사용하는 selector
);
return <div>{hash}</div>;
}
더이상 link를 클릭해도 CurrentPathname 컴포넌트는 리렌더링 되지 않는다.
React.memo 랑 뭐가 다른지?
Concurrent React for Library Maintainers
External state를 사용하면 내부 React system을 사용해서 스케줄링하는 대신 렌더링 도중 상태를 직접 변경할 수 있다. 따라서 외부 저장소에서 concurrent를 지원하려면
1) 렌더링 중에 저장소가 업데이트되었음을 React에 알려 React가 다시 렌더링할 수 있도록 하거나
2) 외부 상태가 변경되면 React가 강제로 중단하고 다시 렌더링하거나
3) 렌더링 중간에 상태가 변경되지 않고 렌더링할 수 있는 다른 솔루션을 구현할 수 있는 어떤 방법이 필요하다.
⚠️ 레벨1. Make it work
Tearing을 그냥 감수하기. useSubscription 훅으로 첫 렌더링이후 동기 업데이트를 실행해서 tearing을 잡는다. 렌더링 도중에 에러가 발생하면 가장 가까운 상위 레벨의 에러 바운더리에서 다시 한번 동기 렌더링을 실행한다. 이 방법은 concurrent rendering을 완전히 포기하는 것이고 flash 효과가 나타날 수도 있다.
✅ 레벨2. Make it right
현재 많은 라이브러리들의 best case. 렌더링이 오래 걸리는 것을 감수하지만 tearing을 바로 잡는 것으로 useSyncExternalStore 훅을 사용해서 rendering 도중에 일어나는 external state 변경사항을 발견하고 tearing이 발생하기 전에 렌더링을 다시 실행한다. 최적화 되지 않음.
🚀 레벨3. Make it fast
속도와 tearing을 모두 손해보지않고 사이드 이펙트를 해결하는 방법으로 아직 external state에서는 연구중인거 같다. 변경 불가능한 스냅샷을 변경 가능한 상태 저장소에 넣고 렌더링 중간에는 스토어를 변경하지 않는방법으로 구현할 수 있다는데.... useContextSelector() 훅이 현재 연구중인데 출시된다면 3단계가 가능할것이라고 예상된다.
useContextSelector
Will this React global state work in concurrent rendering?
React concurrent rendering의 tearing과 branching을 테스트하는 레포
With useTransition
Level 1
1: No tearing finally on update
2: No tearing finally on mount
Level 2
3: No tearing temporarily on update
4: No tearing temporarily on mount
Level 3
5: Can interrupt render (time slicing)
6: Can branch state (wip state)
With useDeferredValue
Level 1
7: No tearing finally on update
8: No tearing finally on mount
Level 2
9: No tearing temporarily on update
10: No tearing temporarily on mount
Redux : useSyncExternalStore를 사용하면서 지원됨. Redux의 상태 업데이트가 완료되는 시점에 React의 렌더링이 일어남. (내부적인 스토어 구독을 구현하는 대신 useSyncExternalStore를 사용하도록 마이그레이션함. 적용PR)
Recoil : 내부적으로 React의 상태감지 기능을 사용하여 상태 업데이트가 발생하면 즉시 React의 렌더링을 트리거함. 따라서 차이가 미미하긴하지만 Recoil이 Redux보다 더 빠른 상태 업데이트와 더 높은 성능을 제공함.
Zustand : 순수 자바스크립트로 구성되어있기 때문에 use-sync-external-store 패키지를 사용해서 지원한다. (적용 PR)