useSyncExternalStore 어후 이름이 너무 길어.

dante Yoon·2022년 10월 3일
37

react

목록 보기
15/19
post-thumbnail

글을 시작하며

글을 시작하기 전에

영상도 있다고?

진짜 글을 시작하며

안녕하세요, 단테입니다.
오늘은 리엑트18의 새로운 훅인 useSyncExternalStore에 대해 알아보겠습니다.
이 훅을 사용해보신 분들은 많이 없을 것 같은데요,
그 이유는 리엑트18에서 제공하는 concurrent feature 기능을 기존 앱에 많이 사용하지 않으셨기 때문입니다. 또 다른 이유는 현재 각 프로젝트에서 사용하고 계신 mobx, redux, recoil(예시입니다. 이 라이브러리들이 패치를 진행했다는 말은 아닙니다)와 같은 external store에서 useSyncExternalStore 사용이 필요없게 패치를 해놓았기 때문입니다.

이 훅에 대해 본격적으로 알아보기에 앞서 먼저 선행학습해야 할 지식들이 있습니다. 아래에서 간단하게 짚고 넘어가보려고 합니다.

concurrent feature

concurrent feature는 무엇일까요?

단테가 Conccurrent Feature에 대해 설명한 글이 이미 있었다구?

네, 자세한 내용은 제 이전 포스트를 참조해주시기 바랍니다.

간단하게 말해서 렌더링 타이밍 도중 사용자의 입력과 같은 즉각적으로 UI에 적용되어야 하는 부분에 대해 우선순위를 정해 렌더링 할 수 있는 기능을 의미합니다.

external store

한글로 external store을 따로 번역해서 사용하는 것은 아직 보지 못했습니다.
리엑트에서 내부적으로 제공하는 useState, useReducer와 같은 상태관리 api가 아니라

자체적으로 상태관리 툴을 만들어 리엑트 훅과 연동시킨 상태관리 라이브러리들을 external store라고 합니다. 대표적으로 mobx, redux, recoil, jotai, xtsate, zustand, rect query등이 있습니다.

이들의 상태 관리 흐름은 리엑트에서 관찰하지 않습니다.

internal store

앞서 나열했던 라이브러리들과 다르게 리엑트에서 제공하는 상태관리 도구입니다. useState, useReducer, context, props가 이에 해당합니다.

왜 나왔는데?

useSyncExternalStore이 해결하는 문제는 concurrent feature에서 발생하는 Tearing이라는 이슈입니다.

으악! 내 나무!

리엑트에서 말하는 tearing은 의도치 않게 상태 불일치로 서로 일치하지 않는 시점의 UI가 렌더링되는 것을 의미합니다.

아니, 단테가 Tearing에 대해서도 글을 썼었다구?!

concurrent feature는 렌더링 도중 들어오는 유저 인터렉션에 대해 기존 렌더링을 중지하고 인터렉션에 대한 UI를 먼저 렌더링 하기 때문에 아래처럼 빨간색으로 렌더링 트리를 업데이트하는 도중 파란색의 이터렉션으로 인해 트리의 일부분이 파란색으로 렌더링될 수 있습니다.

리엑트의 internal store는 이러한 concurrent feature에 대비하여 내부 깊숙한 곳에 상태 처리를 할 수 있는 알고리즘을 구현해놓았지만, 외부 렌더링을 사용할 경우 이러한 리엑트 팀의 노력을 사용하지 못하게 되는 문제가 발생한다는 것이 이슈인데요,

어떤 이슈가 있었는지 보면

redux

redux의 메인테이너 @markerikson은 tearing을 막기위해 리엑트 팀이 만든 useMutableStore를 사용 시 selector 함수를 useCallback으로 감싸줘야 하는 필요성에 대해 이야기를 했습니다.](https://github.com/reactwg/react-18/discussions/84) 이는 기존에 selector 함수를 사용하던 모든 리덕스 사용자들의 불편을 야기하죠.

여기서 selector 함수란 스토어에 있는 여러 값들 중 필요한 값만 불러오거나 원본 상태 값은 그대로 두고 상태의 특정 값만 계산해서 사용하게 도와주는 함수를 의미합니다.

// Arrow function, direct lookup
const selectEntities = state => state.entities

// Function declaration, mapping over an array to derive values
function selectItemIds(state) {
  return state.items.map(item => item.id)
}

// Function declaration, encapsulating a deep lookup
function selectSomeSpecificField(state) {
  return state.some.deeply.nested.field
}

// Arrow function, deriving values from an array
const selectItemsWhoseNamesStartWith = (items, namePrefix) =>
  items.filter(item => item.name.startsWith(namePrefix))

zustand

zustand, jotai를 만든 @dai-shi는 redux 사용 시 selector를 useCallback으로 명시적으로 감싸줘야 하며, zustand의 selector 또한 리엑트18에서는 필수적으로 useCallback으로 감싸줘야 할 것이라고 이야기합니다.

selector 와 useCallback에 대한 이야기가 함께 나오는 이유는 redux와 같은 라이브러리의 경우 자체적으로 useSelector와 같은 리엑트 훅을 제공하지만, useMutableSource 훅은 selector api를 제공하지 않습니다. 따라서 selector를 자체적으로 만들경우, 이 selector가 memoization되지 않은 상태라면 selector가 바뀔 때마다 store를 매번 다시 구독하게 됩니다. selector를 보통 컴포넌트 단위에서 inline으로 작성하기 때문에 컴포넌트가 리렌더링 될 때마다 store도 매번 다시 구독하게 되는 것이죠. 제작자 입장에서는 라이브러리를 사용하는 사람이 알아서 잘 useCallback이나 useMemo로 감싸주길 바랄 수 밖에 없습니다.

이렇게 외부 상태 라이브러리 사용에 대해 tearing을 예방할 때 사용했던 useMutableSource의 selector memoization을 생략할 수 있는
useSyncExternalStore를 만들었는데요, 아래에서 api를 같이 살펴볼까요?

참고 - useMutableSource RFC

useSyncExternalStore

이름부터 externalStore와 어떻게든 synchronize하겠다는 의지가 보이는 이 훅은 아래와 같이 사용됩니다.

이 훅은 external state의 변경사항을 관찰하고 있다가 tearing이 발생하지 않도록 상태 변경이 관찰되면 다시 렌더링을 시작합니다.

const state = useSyncExternalStore(
  subscribe, 
  getSnapshot[, 
  getServerSnapshot]
);
  • subscribe: store가 변경되었을때 호출할 callback 함수입니다.
  • getSnapshot: store의 현재 값을 리턴하는 함수입니다.
  • getServerSnapshot: 서버사이드 렌더링 시 가지고 있던 snapshot을 리턴하는 함수입니다.

실제 호출 코드

const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

store.getSnapshot은 직전 렌더링 시점과 비교해 스토어의 상태 값이 변경되었는지를 확인하기 위해 넣는 값입니다. getSnapShot()은 primitive한 number, string 값일 수도 있고, 메모이제이션된 object일 수도 있습니다.

특정 필드만 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,
);

startTransition

useMutableSource의 대체제인 useSyncExternalStore는 startTransition을 사용하는 리엑트 18에서 useMutableSource가 해결하지 못한 문제점을 해결해줍니다.

startTransition는 웹 사용자의 심리를 사용해 렌더링 우선순위를 정해주는 것입니다. startTransition으로 둘러쌓인 렌더링 업데이트는 낮은 우선순위를 가집니다.

아니 단테가 startTransition에 대한 글도 작성했었다고?

import { startTransition } from 'react';

// Urgent: Show what was typed
setInputValue(input);

// Mark any state updates inside as transitions
startTransition(() => {
  // Transition: Show the results
  setSearchQuery(input);
});

startTransition이 유용하게 쓰일 수 있는 부분은 Suspense 입니다.

아래 코드에서 handleClick 시 comments에 대한 응답을 받아오는 중이라면 fallback UI인 Spinner를 그려줄 것입니다. 하지만 comments 를 불러오기 전에도 <SearchLists/>를 보여주고 싶다면 setCommentQuery를 startTransition으로 감싸주면 됩니다.

function handleClick() {
  setCommentQuery();
}
<Suspense fallback={<Spinner />}>
  {queries === 'searches' ? <SearchLists /> : <Comments />}
</Suspense>;
function handleClick() {
  startTransition(() => {
    setCommentQuery();
  })
}

하지만 setCommentQuery가 externalStore에서 파생된 함수라면 예상처럼 <SearchLists/>를 보여주지 않고 <Spinner/>를 보여주는 문제점이 발생합니다.

ReactConf21에서 이와 같은 startTransition 문제를 useSyncExternalStore를 사용해서 해결하는 예제를 보여줍니다.

어떻게 해결하는지 한번 확인해보겠습니다.

create simple external store

import React, { useState, useEffect, useCallback, startTransition } from 'react'

const createStore = (initialState) => {
  let state = initialState
  const getState = () => state
  const listeners = new Set()
  const setState = (fn) => {
    state = fn(state)
    listeners.forEach((l) => l())
  }
  const subscribe = (listener) => {
    listeners.add(listener)
    return () => listeners.delete(listener)
  }
  return { getState, setState, subscribe }
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()))
  useEffect(() => {
    const callback = () => setState(selector(store.getState()))
    const unsubscribe = store.subscribe(callback)
    callback()
    return unsubscribe
  }, [store, selector])
  return state
}

createStore

먼저 createStore를 살펴보면 초기 상태 값인 initialState를 인자로 받아 내부에서 let state 변수를 초기화 하고

  • getState 함수에서 이 변수를 클로저로 사용하고 있음을 알 수 있습니다.
  • setState 함수는 함수 fn을 인자로 받아 fn(state)를 실행시켜 기존 state를 업데이트해주고 Set()안에 있는 listener들을 순회하며 실행해줍니다.
  • subscribe 함수는 listener 함수를 인자로 받아 Set에 넣어주고 unsubscribe 해줄 수 있는 함수를 리턴해주고 있음을 알 수 있습니다.

useStore

createStore를 사용하는 커스텀 훅인 useStore를 살펴보면 인자로 external store와 selector를 받고

  • selector(store.getState()) 값으로 state를 초기화 시켜줍니다.
  • useEffect 내부를 보면
    • callback 함수는 selectorstore.getState()의 일부를 가져다가 setState로 state를 업데이트 해줍니다.
    • 그리고 store, selector가 변경될 때마다 앞서 선언 해두었던 callback 함수를 실행해 state 값을 최신 값으로 업데이트 시켜줍니다.
    • useEffect의 clean up 함수에서는 external store를 unsubscribe 해주어 Garbage Collector가 사용안하는 메모리 영역을 정리하게 해주고 있습니다.

이제 이 훅을 실제로 사용해보겠습니다.

Counter, TextBox

const store = createStore({ count: 0, text: 'hello' })

const Counter = () => {
  const count = useStore(
    store,
    useCallback((state) => state.count, []),
  )
  const inc = () => {
    store.setState((prev) => ({ ...prev, count: prev.count + 1 }))
  }
  return (
    <div>
      {count} <button onClick={inc}>+1</button>
    </div>
  )
}

Counter 컴포넌트 내부에서 count변수는 useStore에서 리턴되는 값을 참조하고 있습니다.
inc 함수는 external store의 현재 상태 값을 참조해서 카운터를 하나 올리고 있습니다.

  • inc를 호출할경우 external store의 setState를 호출합니다.
  • external store의 setState는 useStore 내부에서 subscribe 했던 콜백함수를 호출합니다. 이 경우 콜백 함수는 useStore에서 선언했던 useState의 반환 값중 setState 함수입니다.
  • 업데이트된 count가 Counter 컴포넌트 내부에서 표시됩니다.
const TextBox = () => {
  const text = useStore(
    store,
    useCallback((state) => state.text, []),
  )
  const setText = (event) => {
    store.setState((prev) => ({ ...prev, text: event.target.value }))
  }
  return (
    <div>
      <input value={text} onChange={setText} className="full-width" />
    </div>
  )
}

const App = () => {
  return (
    <div className="container">
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  )
}

위 코드에서 startTransition을 사용한다면 store가 external store이기 때문에 tearing 현상이 일어날 수 있습니다.

useSyncExternalStore 사용하기

AFTER

import { useSyncExternalStore } from 'react'

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState(), [store, selector])),
  )
}

useExternalStore를 사용해서 useStore 내부를 변경했습니다. 아주 간단하게 업데이트된 상태값을 조회할 수 있게 되었습니다.

아래는 useSyncExternalStore를 사용하기 전의 코드입니다.

BEFORE

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()))
  useEffect(() => {
    const callback = () => setState(selector(store.getState()))
    const unsubscribe = store.subscribe(callback)
    callback()
    return unsubscribe
  }, [store, selector])
  return state
}

subscribe api를 제공하는 어떤 store든지 상관 없이 useSyncExternalStore를 활용하면 concurrent feature에서 발생하는 tearing을 예방할 수 있습니다.

그림: React Conf 21 React18 for External Store Libraries - Daishi Kato 16:35

기존에 external store와 함께 사용하돈 useState, useEffect, useRef 로직이 있었다면 useSyncExternalStore를 사용해 migration 하는 것이 더 좋아 보입니다.

글을 마치며

오늘은 useSyncExternalStore를 사용하는 이유와 어떻게 사용하는지를 예시코드와 함께 알아보았습니다.

긴 글 읽어주셔서 감사합니다.

profile
성장을 향한 작은 몸부림의 흔적들

7개의 댓글

comment-user-thumbnail
2022년 10월 3일

요즘은 별 건 아니지만 시리즈가 안올라오네요 호호 (기다리는 중입니다)

1개의 답글
comment-user-thumbnail
2022년 10월 3일

우와 이런게 있었군요,, 좋은거 배워갑니다~!

1개의 답글
comment-user-thumbnail
2022년 10월 7일

Thanks for sharing this post. I got something most important tips.
Intel Dinar Recaps

답글 달기
comment-user-thumbnail
2022년 11월 13일

다양한 참고자료를 통해서 깊이 있게 서술해주셔서 너무 좋았어요!
정말 깊이 공부하시는 것 같아요! 나오게 된 배경, redux 등 라이브러리의 대응 등 어떻게 다 끌고 오셨을까... 싶네요. 정말 대박입니다!! tanstack 코드에서도 이 훅을 사용하던데, 왜 이것을 사용하는지 조금이나마 이해할 수 있었습니다.

다만 예시로 나온 코드가 어떤 배경에서 어떻게 나온 코드인지 몰라서 이해가 조금 파악이 어려웠답니다!!

function handleClick() {
setCommentQuery();
}
<Suspense fallback={}>
{queries === 'searches' ? : }
;

답글 달기
comment-user-thumbnail
2023년 6월 19일

좋은 글 감사합니다!

https://www.youtube.com/watch?v=ZKlXqrcBx88&t=1s

이 영상 보다가 궁금해서 찾아보고 있었는데 이해가 되었습니다!

답글 달기