0. concurrent Feature(동시성)
- 원래 리액트 18 이전의 렌더링이란 개입할 수 없는 단 하나의 동기적인 처리였기 때문에 한 번 렌더링이 시작되면 이를 중단할 수는 없었습니다.
- 리액트 18은 렌더링 자체에 개입하고 이를 중단하거나 재개하고 또는 폐기할 수 있게 되었습니다.
무거운 렌더링 작업을 하는 동안에 사용자가 원하는 동작(유저 인터렉션)에 따른 우선순위를 새로 부여하여 즉각적으로 hydration 순위를 바꿔서 더 빨리 반응할 수 있게 된 것입니다. concurrent rendering(동시성 렌더링)의 도입 덕분에 리액트 18에서는 suspense, streaming server rendering, transition 과 같은 새로운 기능이 소개될 수 있었습니다.
동시성 덕분에 심지어 진행 중이었던 렌더링을 완전히 포기할 수도 있다. 전체 렌더링 트리가 평가될때까지 돔 조작을 제일 나중으로 미룬다. 이 기능 덕분에 리엑트는 메인 스레드를 막지 않고 백그라운드에 새로운 화면을 준비해둘 수 있다.
(from. 단테 님의 블로그)
1. Automatic Batching
이전에는 React Event Handlers 내부에서만 batched update가 가능했습니다.
React18부터는 Promise나 setTimeout, native event handlers 혹은 다른 이벤트에도 batched update가 적용됩니다.
// React v18 이전
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
/**
setCount에서 한번
setFlag에서 한번
총 두 번 rendering 된다.
**/
}, 1000);
// React v18
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// batch update가 가능하기 때문에 rendering을 한번만 진행
}, 1000);
2. tearing 현상
2-1. tearing 개요
의도치 않게 여러 가지의 UI가 표현되는 것을 의미합니다.
자바스크립트는 싱글 스레드에서 동작하므로 이런 일이 벌어지지 않습니다.
하지만 React 18 에서는 concurrent feature 의 도입으로 렌더링 도중 우선순위가 높은 UI 변경에게 쓰레드를 위임하기 때문에 이런 문제가 생길 수 있습니다. 하나의 동작을 끝마치기 전에 다른 동작을 수행할 수 있기 때문입니다.
concurrent feature가 적용된 트리를 살펴보도록 합시다.
예를 들어 빨간색 혹은 초록색으로 트리 색깔을 바꿀 수 있는 네모난 토글 버튼이 있다고 가정해봅시다.

빨간색 버튼을 눌러 트리가 렌더링 되던 도중에 사용자가 초록색 버튼을 누르게 되면 이미 렌더링이 끝난 두 개의 동그라미만 빨간색 버튼으로 표시가 되고 나머지 버튼들은 초록색으로 표시가 될 것입니다.
상태가 도중에 변경되었으므로 프로그래머의 의도와는 다르게 한 앱에서 여러 개의 상태와 UI가 불일치하는 tearing 문제가 생기게 되는 것입니다.
2-2. internal store와 external store
internal store
리액트에서 제공하는 상태관리 도구입니다. 잘 알고 계시는 useState, useReducer, context, props가 이에 해당됩니다.
external store
자체적으로 상태관리 툴을 만들어 리엑트 훅과 연동시킨 상태관리 라이브러리들을 의미합니다.
대표적으로 mobx, redux, recoil, jotai, zustand, react query 등이 있습니다.
이들의 상태 관리 흐름은 리엑트에서 관찰하지 않습니다.
internal store[state]를 사용할 경우 렌더링을 유발시킨 상태에 대한 업데이트가 렌더링 도중 일어난다면, 리엑트는 똑똑하게 다시 렌더링을 수행할 필요 없이 최신 상태를 참조하여 렌더링을 수행할 수 있도록 구현해놓았습니다.
tearing 현상을 막기 위해서 내부적으로 상태 업데이트 큐잉에 대한 알고리즘을 구현해 놓았다는 것인데, external store[state]를 사용할 경우 이러한 리엑트의 노력이 깨지게 됩니다.
위와 같은 이유 때문에 외부 상태를 사용할 때는 아래 세 가지 조건 중 한 가지를 만족시켜서 concurrent feature를 대비할 수 있어야 한다고 합니다.
1. 리엑트에게 상태가 업데이트 했으니 다시 렌더링 해야 한다고 알려준다.
2. 리엑트가 렌더링을 중단하고 다시 최신 값을 참조해 렌더링 하게 한다.
3. 리엑트가 렌더링 중에는 상태 값을 변경 못하게 해야 한다.
2-3. 최종적인 해결방법
(1) useSyncExternalStore 훅의 사용
tearing을 바로잡는 대신 렌더링이 오래 걸릴 수도 있는 상황을 감수하는 방법으로 useSyncExternalStore 훅을 사용하는 것입니다. 이 훅을 사용하게 되면 rendering 도중에 일어나는 external state의 변경사항을 발견하고 tearing이 발생하기 전에 렌더링을 다시 실행하게 됩니다.
(2) 처음부터 internal state를 사용
아예 처음부터 internal state를 사용하는 것입니다.
3. useSyncExternalStore
3-1. selector와 메모이제이션
selector
- selector 함수는 스토어에 있는 여러 값들 중 필요한 값만 불러오거나 원본 상태 값은 그대로 두고 상태의 특정 값만 계산해서 사용하게 도와주는 함수를 의미합니다.
- 외부 스토어를 사용 시 이 selector를 리액트 18에서는 useCallback으로 감싸줘야 한다고 얘기가 나왔었는데요.
그 이유는 외부 스토어에서 selector를 자체적으로 만들 경우, memoization 되지 않은 상태라면 selector가 바뀔 때마다 store를 매번 다시 구독하기 때문입니다. => selector를 컴포넌트 단위에서 inline으로 작성하기 때문에 컴포넌트가 리렌더링 될 때마다 store도 매번 다시 구독하게 되는 것입니다. 그래서 useCallback이나 useMemo로 메모이제이션을 해야 한다는 얘기가 나왔습니다.
이렇게 외부 상태 라이브러리 사용에 대해 tearing을 예방하기 위한 대체자로 useSyncExternalStore라는 것이 만들어졌습니다.
3-2. useSyncExternalStore
const state = useSyncExternalStore(
subscribe,
getSnapshot[,
getServerSnapshot]
);
이 훅은 external state의 변경사항을 관찰하고 있다가 tearing이 발생하지 않도록 상태 변경이 관찰되면 다시 렌더링을 시작합니다.
실제 호출 코드
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
store.getSnapshot 은 직전 렌더링 시점과 비교해 스토어의 상태 값이 변경되었는지를 확인하기 위해 넣는 값입니다.
특정 필드만 subscribe 하는 코드
const selectedField = useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().selectedField,
);
두번째 () => store.getSnapshot().selectedField가 selector입니다.
이 useSyncExternalStore에 들어가는 selector는 메모이제이션 되지 않아도 됩니다.
세번째 인자인 getServerSnapshot은 hydration시 일어나는 server, client 상태 값의 mismatch를 방지하기 위해 사용합니다.
const selectedField = useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().selectedField,
() => INITIAL_SERVER_SNAPSHOT.selectedField,
);
3-3. 외부 스토어 훅 구현
위를 바탕으로 간단한 외부 스토어를 만들어보겠습니다. 전체 코드를 보면서 하시려면 🔗이 깃헙 링크를 참조하시면 됩니다.
먼저 스토어를 구현해줍니다.
스토어에는 todos라는 external state와 이 external state를 subscribe(sub/unsub), get, set 할 수 있는 메서드를 todoListStore라는 객체 안에 담아주었습니다.
- sub/unsub 함수는subscribe 메서드
- get 함수는 getSnapshot 메서드
- set 함수는 addTodo 메서드입니다.
또한 추가적으로 emitChange라는 함수가 external state의 상태를 바꿀 수 있는 addTodo 메서드 안에 들어있는데요?
- emitChange 함수를 보면 for 반복문에서 비어있는 배열인 listeners 식별자의 내부 값을 listener 라고 const 선언 후 그 listener 함수를 모두 실행하도록 해주는 함수임을 알 수 있습니다.
- 현재 초기에는 listeners 배열은 비어있는 상태이죠. 이 listeners 배열에는 컴포넌트단에서 subscribe 메서드를 호출할 때 subscribe 메서드의 인자로 들어오는 listener가 담기게 됩니다. 여기서 그 listener 인자가 콜백함수 형태로 들어오게 됩니다. 컴포넌트단에서 listener 콜백함수를 구현할 때는 listener 콜백함수 내부에서 현재 external state에 접근할 수 있는 todoListStore.getSnapshot() 메서드를 사용하게 됩니다.
- 즉, emitChange 함수의 역할을 정리하자면, subscribe 메서드를 통해 스토어를 구독하고 있는 컴포넌트들에게 addTodo 메서드로 인해 external store state의 변화가 생겼을 때 subscribe 함수의 콜백 함수(listener)를 전부 다 실행하도록 도와줍니다. 컴포넌트단의 모든 subscribe 메서드의 listener 콜백함수가 실행되겠네요.
🎁 스토어 코드
let nextId = 0;
/**
* external store state
*/
let todos = [{ id: nextId++, text: 'Todo #1' }];
/**
* callback function Array which can notify change of the store state.
*/
type Tfunction = () => void;
let listeners: Tfunction[] = [];
const emitChange = () => {
for (const listener of listeners) {
listener();
}
};
export const todoListStore = {
/**
* @param listener Functions to run when the state changes (=== subscribe callback function)
* @returns returns `unsubscribe` function. Unsubscribe function helps components to have independent subscription.
* @example
* ```ts
*
* useEffect(() => {
const unsub = todoListStore.subscribe(() => {
const toDolist = todoListStore.getSnapshot();
console.log(`🚀 toDoList count: ${toDolist.length}`);
});
return () => unsub();
}, []);
* ```
*/
subscribe(listener: Tfunction) {
listeners = [...listeners, listener];
/**
* unsubscribe function helps components to perform independent subscription.
*/
return () => {
listeners = listeners.filter(lis => lis !== listener);
};
},
getSnapshot() {
return todos;
},
addTodo(text: string) {
todos = [...todos, { id: nextId++, text }];
emitChange();
},
};
🎁 Input.tsx에 다음과 같이 컴포넌트를 구성합니다.
- addTodo 메서드를 통해 새로운 input 입력값을 통해 store state를 업데이트 해줍니다.
import React, { useRef } from 'react';
import Button from './common/Button';
import { todoListStore } from '@/store/store';
const Input = () => {
console.log('🚀 Input rendered');
const input = useRef<HTMLInputElement>(null);
return (
<div className='f-fr'>
<input
ref={input}
className='rounded-sm border capitalize'
maxLength={20}
placeholder='input something...'
/>
<Button
intendedColor='secondary'
size={'md'}
onClick={() => {
/**
* @description external store state => trigger rendering every seconds whenever changes are made to the list(external store state).
*/
todoListStore.addTodo(
input.current?.value ? input.current?.value : '',
);
}}
>
+
</Button>
</div>
);
};
export default Input;
🎁 useSyncExternalStore 사용
- useSyncExternalStore를 통해 각 첫 번째, 두 번째 인자에 subscribe 메서드, getSnapshot 메서드를 넣어서 전체 todoList external state(todos)를 반환받습니다.
- 이 때 반환되는 값인 todoList 의 타입추론은 두 번째 인자인 getSnapShot 메서드의 반환 값을 통해 이루어집니다
import { todoListStore } from '@/store/store';
import React, { useSyncExternalStore } from 'react';
const TodoListView = () => {
console.log('🚀 View rendered');
/**
* @description external store state => trigger rendering every seconds whenever changes are made to the list(external store state).
*/
const todoList = useSyncExternalStore(
todoListStore.subscribe,
todoListStore.getSnapshot,
);
return (
/**
* * list-style-position: inside;
* * list-style-type: decimal;
*/
<ul className='list-inside list-decimal pl-5'>
{todoList.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
};
export default TodoListView;
🎁 Subscribe
- Subscribe 메서드의 특징은 컴포넌트단에 subscribe 메서드 자체만으로는 렌더링을 발생시키지 않는다는 것입니다. (listener 콜백 함수 내부에 state setter 함수와 엮는다면 렌더링을 유발시킬 수 있습니다.)
- 또한 컴포넌트가 unmount 될 때 이 listener 함수가 스토어에서는 사라지지 않기 때문에 수동으로 listeners 배열에서 지워야합니다. 우리가 위에서 스토어 코드에서 subscribe 메서드를 만들었을 때 return 으로 filter 함수를 반환하는데 이 filter 함수를 통해 컴포넌트가 unmount 될 때 unsubscription 행위를 통해 구독 해제를 할 수 있도록 해야 합니다.
- 구독 해제를 하지 않는다면 컴포넌트가 unmount 되어도 external state가 변화될 때마다 계속해서 콘솔을 찍게됩니다. 그렇기 때문에 unsubscription 행위로서 componentWillUnmount 의 역할을 수행하는 useEffect clear 함수에 subscribe 메서드가 반환하는 filter 함수인 unsub 함수를 적어줍니다.
import { todoListStore } from '@/store/store';
import { useEffect } from 'react';
const Console = () => {
/**
* No render
*/
console.log('🚀 Console rendered');
useEffect(() => {
const unsub = todoListStore.subscribe(() => {
const toDolist = todoListStore.getSnapshot();
console.log(`🚀 toDoList count: ${toDolist.length}`);
});
return () => unsub();
}, []);
return <>See console</>;
};
export default Console;
외부 스토어를 직접 구현해보면서 여러 라이브러리가 어떤 식으로 구현이 가능한 지 생각해 볼 수 있었던 것 같습니다.
레퍼런스
https://velog.io/@moonelysian/React-18-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0
React 18 살펴보기
React18은 뭐가 달라졌을까?
velog.io
https://www.youtube.com/watch?v=7mkQi0TlJQo
리액트 18의 신기능 - 동시성 렌더링(Concurrent Rendering), 자동 일괄 처리(Automatic Batching) 등
> 2022년 3월에 리액트 18이 발표되었습니다. 성능 향상과 렌더링 엔진 개선에 초점이 맞춰졌습니다. 리액트 18은 향후 출시될 리액트 기능의 토대가 될 동시성 렌더링 API의 초석을 다졌습니다. 이
www.freecodecamp.org
https://ingg.dev/zustand-work/
[React] Zustand 동작 원리와 ExternalStore
최근 zustand를 이용하여 상태 관리를 해보았는데 사용법이 정말 간단하다고 느꼈다. 특이한 점은 redux, recoil 과 달리 앱을 감싸는 별도의 provider가 없이도 상태 관리가 가능하다는 것이였는데, 그
ingg.dev