ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React 18 - Transition과 외부 상태 관리 도구
    Study/개발 2023. 12. 10. 02:04
    이전 글: React 18 Transition

     

    이전 글에서 트랜지션을 활용하여 UX를 개선하는 예시를 살펴보았습니다. 리액트에서 제공했던 기본 예시는 상태관리로 useState하나만 사용했지만, 실제 어플리케이션의 경우 대부분 외부 상태 관리 도구를 사용하게 됩니다. 여기서 Transition 적용에 어려움이 발생합니다.

    이 포스팅에서는 외부 상태 관리 도구와 Transition을 함께 사용하려 할 때에 발생하는 문제점과 해결방안에 대해 공유하려합니다.

     

    외부 상태 관리자의 도입 배경

    React에서는 상태관리를 위해 기본적으로 useState, useContext 등을 제공합니다. 하지만 어플리케이션의 크기가 커지고 복잡도가 높아지게되면서 상태 관리를 위해 외부 스토어를 사용하는 경우가 많습니다. 예를 들어 가장 대표적인 Redux부터 Jotai, Zustand 등 다양한 외부 상태관리 도구들이 존재합니다.

    이러한 라이브러리는 애플리케이션의 상태를 중앙에서 관리하게 하거나, 관심사 별로 상태를 묶어서 관리하여 컴포넌트 간 상태 공유를 용이하게 합니다.

     

    Transition의 문제 - Tearing

    Transition 도입 시 문제가 되었던 부분은 외부 상태관리 라이브러리와의 호환성이었습니다.
    React의 Concurrent render는 외부 스토어와 함께 사용하면 Tearing 현상이 나타난다는 이슈가 있습니다.

    Tearing

    상태가 둘로 찢어지는 현상.
    렌더가 일어나는 중에 A컴포넌트에서 1이라는 값을 참조했다면, 그 뒤에 값이 바뀌고 B 컴포넌트에서는 다른 값을 참조하게 된다.

    읽어보기: What is tearing

    tearing 현상

    출처:  https://github.com/pmndrs/jotai/discussions/2137

     

    이를 해결하기 위해 React Redux(v8)에서는 useSyncExternalStore를 사용합니다.

    useSyncExternalStore - https://react.dev/reference/react/useSyncExternalStore

    useSyncExternalStore라는 이름이 시사하듯이, 이는 ExternalStore의 상태를 Sync하게 사용합니다. 즉, 외부 스토어의 상태 업데이트를 강제로 높은 우선순위의 업데이트로 보고 블로킹 업데이트를 합니다. 이 경우, Concurrent React의 강점인 Time Slicing을 사용할 수 없습니다.

    cf)
    Jotai 같은 라이브러리는 Time Slicing이 안되는 경우를 방지하면서 tearing을 막기 위해 useSyncExternalStore를 사용하지 않고 Double-render-with-one-commit 방식으로 이를 해결합니다.

     

    Redux 예

    Redux 예를 보겠습니다. useTransition 리액트 공식 예제(https://react.dev/reference/react/useTransition)를 Redux를 사용하는 방식으로 변경 해보았습니다.

    export default function TabContainer () {
      const tab = useSelector((state: RootState) => state.tab.value);
      const dispatch = useDispatch();
    
      const [isPending, startTransition] = useTransition();
    
      const selectTab = (value: "about" | "posts" | "contact") => {
      // Not working!!
        startTransition(() => {
          dispatch(changeTab(value));
        });
      };
    
      return (
        <>
          <TabButton isActive={tab === "about"} onClick={() => selectTab("about")}>
            About
          </TabButton>
          <TabButton isActive={tab === "posts"} onClick={() => selectTab("posts")}>
            Posts (slow)
          </TabButton>
          <TabButton
            isActive={tab === "contact"}
            onClick={() => selectTab("contact")}
          >
            Contact
          </TabButton>
          <hr />
          {tab === "about" && <AboutTab />}
          {tab === "posts" && <PostsTab />}
          {tab === "contact" && <ContactTab />}
        </>
      );
    }
    
    

    위와 같이 state를 외부로 옮긴다면 리액트에서 transition으로 감지를 할 수가 없습니다. 퍼포먼스탭을 보면 알 수 있습니다. 이전 글에서 Transition이 없이 useState만 사용했을 때와 같이 커다란 블로킹 태스크가 나타나는 것을 볼 수 있습니다.

     

    문제 해결 방법

    공식적인 대안은 아직 없는걸로 보입니다. 관련 react-redux 이슈

    다만 몇가지 workaround가 존재합니다. 필요한 부분에 대해 리액트 내부 상태가 되도록 상태를 분리시키는 것입니다.

    1. useDeferredValue
    2. useContext

    useDeferredValue 사용하기

    useDeferredValue 는 상태값을 defer 시켜주는 훅입니다. 외부 스토어의 상태를 한 번 감싸서, 클릭으로 인해 발생한 렌더가 끝난 직후 (이 렌더에서는 기존 값을 사용) 새로운 값으로 렌더가 일어나게 해줍니다. 이 때 새로운 값으로 일어나는 렌더는 non-blocking으로, Transition처럼 중간에 멈출 수 있습니다.

    export default function TabContainer() {
      const tabState = useSelector((state: RootState) => state.tab.value);
      // defer tabState
      const tab = useDeferredValue(tabState);
      const dispatch = useDispatch();
    
    
      const selectTab = (value: "about" | "posts" | "contact") => {
        dispatch(changeTab(value));
      };
    
      return (
        <>
          <TabButton isActive={tab === "about"} onClick={() => selectTab("about")}>
            About
          </TabButton>
          <TabButton isActive={tab === "posts"} onClick={() => selectTab("posts")}>
            Posts (slow)
          </TabButton>
          <TabButton
            isActive={tab === "contact"}
            onClick={() => selectTab("contact")}
          >
            Contact
          </TabButton>
          <hr />
          {tab === "about" && <AboutTab />}
          {tab === "posts" && <PostsTab />}
          {tab === "contact" && <ContactTab />}
        </>
      );
    }

    마찬가지로 퍼포먼스탭을 살펴보면, Time Slicing이 잘 적용된 것을 알 수 있습니다.

     

     

    useContext 사용하기

    개선이 필요한 상태들에 대해 리액트 내부에서 관리하는 훅인 useContext로 이동시키는 것입니다. 방법은 가장 간단하지만, useContext는 불필요한 리렌더같은 단점도 있고, 상태가 분화되어 관리포인트가 늘어날 수 있습니다.

     

    결론

    렌더링이 느린 현상이 문제가 된다면 React 18의 Concurrent rendering을 이용할 수 있습니다. UX를 부드럽게 해주는 좋은 기능인만큼 더 좋은 사용자 경험을 전달할 수 있을 것입니다.

    하지만 외부 스토어와의 연동 시 어려운 부분이 있을 수 있고, 문제가 될 수 있습니다. 이런 Concurrent Feature의 Trade-off에 대해 이해하고 테스트 해보며 사용하는 것이 중요해보입니다.

    틀린 내용이 있다면 댓글 부탁드립니다.

    728x90

    'Study > 개발' 카테고리의 다른 글

    React 18 - Suspense 분석하기  (0) 2024.02.21
    Neovim  (2) 2023.12.28
    React 18 - Transition  (0) 2023.12.09
    Wasm Book 튜토리얼  (0) 2023.11.12
    Stop Nitpicking in Code Reviews (번역)  (2) 2023.10.20
Designed by Tistory.