useSyncExternalStore

김대은·2022년 11월 4일
0

리엑트 18의 새로운 훅인 useSyncExternalStore에 대해 알아보려한다.
이 훅은 사용해본적이 없으며, 찾아볼 일도 많이 없었다.
그 이유는 리엑트 18에서 제공하는 concurrent feature기능을 기존 앱에 많이 사용하지 않기때문이다.
또 다른 이유는 현재 각 프로젝트에서 사용하고 있는 mobx,redux,recoil과 같은 exteranal store에서 useSyncExternalStore사용이 필요없게 패치를 해놓았기 때문이다.

이 훅에 대해 본격적으로 알아보기에 앞서 먼저 선행해야 할 지식들이 있다.

concurrent feature

자세한 내용이 아래 링크에서 확인 가능합니다.
https://velog.io/@jay/Concurrent-React

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

external store

한글로 external store를 따로 번역해서 사용하는 것은 아직 보지 못했다.
리엑트에서 내부적으로 제공하는 useState,useReducer와 같은 상태관리 api가 아니라
자체적으로 상태관리 툴을 만들어 리엑트 훅과 연동시킨 상태관리 라이브러리들을 external state라고 합니다.

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

internal store

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

왜 생겼는가?

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

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

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

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

어떤 이슈가 있었는지 보면

redux

redux의 메인테이너는 tearing을 막기 위해 리엑트팀이 만든 useMutableSource를 사용 시 selctor 함수를 useCallback으로 감싸줘야하는 필요성에 대해 이야기를 했습니다.
이는 기존에 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))

useSyncExternalStore

이름부터 external store와 synchronize하겠다는 의지가 보이는 이 훅은 아래와 같이 사용합니다.

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

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

실제 호출 코드

const state = useSyncExternalStore(store.subsribe, store.getSnapshot)

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

특정 필드만 subscribe하는 코드

const selectedField = useSyncExternalStore(
 store.subscribe,
 ()=> store.getSnapshot().selectedField,
);

두번째 ()=>store.getSnapshot().selectedFieldselector입니다.
이 useSyncExternalStore에 들어가는 selector는 메모이제이션 되지 않아도 됩니다.

세번째 인자인 getServerSnapshot은 hydration시 일어나는 server,client 상태 값의 mismatch를 방지하기 위해 사용합니다.

const selectedField = useSyncExternalStore(
 store.subscribe,
 ()=>store.getSnapshot().selecetedField,
 ()=>INITIAL_SEVER_SNPASHOT.selectedFiled,
);

startTransition

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

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이 유용하게 쓰일 수 있는 부분은 Suspence 입니다.

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

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

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

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

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

create simple external store


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를 인자로 받아 내부에서 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함수는 selector로 store.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내부를 변경했습니다.
아주 간단하게 업데이트된 상태값을 조회할 수 있게 되었습니다.

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

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을 예방할 수 있습니다.

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

출처

dante.log

profile
매일 1% 이상 씩 성장하기

0개의 댓글