이제 서버 컴포넌트와 클라이언트 컴포넌트를 모두 사용할 수 있는 그럴듯한 앱이 되었다. 하지만 잊으면 안 된다. 이 앱은 아주 원시적인 앱이라는 것을.
목록에서 아이템을 클릭하는 것도 <a>
태그고
검색을 통해 새로운 목록을 받아오는 것도 <form>
태그다.
즉, 데이터 페칭이 일어날 때마다 완전히 새로운 UI를 다시 받아와 전체 페이지가 새로고침 된다. 이것은 좋은 유저 경험이 아니다.
게다가 pushState
와 popState
도 적용되어 있지 않다.
이제 우리는 클라이언트 컴포넌트를 사용할 수 있으니, 서버에서 모든 HTML을 다시 받아오는 전통적인 방식에서 벗어나 RSCs 기반의 SPA처럼 동작하도록 클라이언트 라우팅을 추가해보도록 하자.
먼저, navigate
함수를 만들어보자.
이렇게 item을 클릭하면 item.id가 path param으로 붙는다. path param의 상태를 관리하여 변경이 일어날 때마다 새롭게 데이터 패칭을 해주면 된다.
path param에 대한 상태와 서버로 부터 받은 UI 상태를 관리할 useState를 만든다.
navigate
함수는 path param을 받아서 새로운 payload를 호출하고, payload를 받아서 ReactElement로 변환한다.
아직 별다른 것은 없는데, navigate
가 전역에서 쓰일 수 있도록 RouterContext
가 필요하다.
라우터 관련 모듈이 위치할 router.js
을 만들고 context를 생성한다.
그리고 root react element를 감싸주자.
자, 여기까지하면 라우팅에 대한 기본적인 세팅이 완성이다. 이제 <a>
를 클릭 시 브라우저의 기본 동작인 페이지 전체 새로 고침을 방지하고, navigate
로 작동하게 해야한다.
DOM에 document.addEventListener('click', onClick)
를 추가할
useLinkHandler
를 만들어보자.
navigate가 가능한 경우만 조건을 준 뒤 preventDafult
후 만들어둔 navigate
를 실행하게 된다.
이제 앱을 실행시켜보면 새로고침 없이 데이터를 받아오는 걸 확인할수 있다. (브라우저의 새로고침이 작동하지 않는다!)
여기서 더 수정을 하자면 먼저 요소를 클릭할 때마다 보이는 fallback 이미지가 조금 거슬린다. 그리고 replaceState
와 pushState
가 없어서 목록에 있는 아이템을 클릭해서 path param이 변경되어도 브라우저 URL는 변경되지 않는 것이다.
먼저 반짝거리는 fallback 이미지를 수정하자. 이건 간단하게 해결 가능하다. 즉시 리렌더링을 하지 않고, 이전 UI를 유지한 상태에서 새 데이터를 준비하는 것이다. startTransition
으로 해결할 수 있다.
setContentPromise
가 startTransition
내에서 실행되면서 우선순위가 낮아지고, React는 이전 UI를 유지한다. 동시에 백그라운드에서 새 contentPromise
데이터 준비하다가 resovle되면 한꺼번에 UI가 변경된다.
index.js
의 navigate
안에 위치한 setContentPromise
를 수정하고 앱을 다시 실행시켜보면
Suspense에 걸리지 않고 부드럽게 데이터 페칭이 잘 이루어지는 것을 확인할 수 있다.
이제 replaceState
와 pushState
를 사용해서 클라이언트 내비게이션과 URL 동기화를 해야 한다.
URL도 잘 바뀌는 것을 확인했다!
클라이언트 라우팅도 어느정도 완성되었으니, 사용자 경험에 대해 생각해보자. 앱은 문제 없이 잘 작동한다. 하지만, 네트워크 지연이 발생한 환경에서도 동일한 사용자 경험을 줄 수 있을까? 느린 네트워크에서의 대응은 언제나 중요하다!
사용자가 검색을 했을 때 리스트를 조회하는 API에 지연이 발생했다고 가정하자. delay
가 3초가 생겼다.
input은 입력되었지만 네트워크 지연으로 서버 컴포넌트가 느리게 렌더링 됨에 따라 사용자는 3-4초동안 아무런 상호작용도 느끼지 못하게 된다.
이번엔 아이템을 클릭해서 디테일 화면으로 넘어가보자.
디테일 컴포넌트도 마찬가지로 클릭 이후 3-4초동안 반응이 없다! 이는 사용자에게 앱이 멈췄다는 인상을 줄 수 있다. 느린 네트워크 환경에서도 앱이 전환중이라는 느낌을 줘야하는 것이다.
단순히 데이터 페칭이 느린 것이니까, 로딩 스피너를 보여주면 되지 않냐라고 할 수 있겠지만 간단한 문제가 아니다.
두 컴포넌트 모두 서버에서 렌더링 되는 서버 컴포넌트다. 서버 컴포넌트이기 때문에 서버 상태에 따라 UI를 다르게 렌더링할 수 없다.
그래서 여기서 등장한 것이 useTransition
와 useDeferredValue
다. RouterContext
에서 path param을 관리하고 있으니, 현재 검색어와 다음 검색어(data fetching 중인)를 비교하여 Pending UI를 적절하게 보여주면 될 것 같다.
먼저, 디테일 컴포넌트(SearchDetail
)를 수정해보자
말했던 것처럼SearchDetail
은 서버 컴포넌트다. RouterContext
를 사용할 수 없다. 그렇다면 SearchDetail
props로 받는 클라이언트 컴포넌트를 만든 뒤, 그곳에서 펜딩 작업을 하면 될 거 같다.
serachDetailPending
이라는 클라이언트 컴포넌트를 만든다.
이제 children
으로 SearchDetail
를 받으면 된다.
이제 우리는 클라이언트 훅을 사용할 수 있다. RouterContext
를 사용하고 있는 useRouter
를 호출하자.
우리에게 필요한 건 현재 검색어와 다음 검색어(data fetching 중인)이다. RouterContext
에 provide하고 있는index.js
에서 변수명을 수정해보자
location
은 nextLocation
으로 변경하고 location
은 어디서 얻느냐? 바로 useDeferredValue
다.
useDeferredValue
는 특정 값의 업데이트 우선순위를 낮춘다. 즉, nextLocation
은 즉각적으로 업데이트 되지만 location
은 트랜지션이 완료되어야 업데이트 된다.
이 두 값을 비교하여 Pending UI를 보여주면 된다.
다시 SerachDetailPending
으로 가서,
현재와 다음을 비교하여 다르다면 아직 트랜지션 중인 것이니 pending을 준다. (트랜지션 중이면 Opacity로 처리함)
이제 마지막으로 App.js
가서 수정하면 완료. 그럼 Pendin UI가 잘 나오나 확인해보자.
트랜지션 중에는 Pending을 보여주고 트랜지션이 완료되기 전까지 이전 UI를 유지하다 트랜지션이 종료되면 새로운 UI로 렌더링되는 것을 확인할 수 있다!
다음으로 검색 컴포넌트도 동일하게 펜딩을 적용해보면
느린 네트워크에서도 화면 전환중임을 정확하게 표현하고 있다!
아직 끝나지 않았다. 이번엔 브라우저 뒤로가기/앞으로가기에 대한 이슈다
우리 앱은 아이템을 선택에 따라 URL이 바뀌지만 뒤로가기/앞으로가기를 했을 때는 반대로 렌더링이 되지 않는다. 한번 기능을 추가해보자.
간단하게 window api인 popState
로 구현 가능하다.
index.js
에서 useEffect를 추가한다. handlePopState
의 내부 로직은 서버 컴포넌트 렌더링 로직과 동일하다. 이제 앱을 확인해보자
브라우저 히스토리에 따라 앱이 잘 렌더링 되는 것을 확인했다!
그런데 뭔가 다른 점이 느껴지는데, 뒤로가기/앞으로가기를 하게 되면 전체 페이지가 새로고침되는 것이다.
전체 페이지가 새로고침되면서 전체 UI를 다시 받아와서 fallback이 매번 뜬다.
분명 startTransition
으로 상태 업데이트를 지연시켰는데도 왜 이런 문제가 발생하는 걸까? 원인은 브라우저 캐시에 있다.
일반적인 SPA에선 브라우저가 앞뒤로 페이지 이동 시 페이지의 내용, DOM 상태, 스크롤 위치, 입력값 등등을 캐시한다.
하지만, 우리의 예제는 pushState
와 replaceState
로 URL 업데이트를 제어하고 popstate
로 앞뒤 브라우저 이동을 제어하기 때문에 popstate
이벤트 발생할 때마다 리액트는 새로운 서스펜스를 만들어야 한다고 판단하여 fallback UI와 함께 페이지가 새로고침 되는 것처럼 보인다.
그래서 브라우저 캐시를 흉내내기 위해서는 자체적으로 페이지를 캐싱해야 한다!
먼저 캐시를 관리한 useContentCache
를 만든다.
useContentCache
는 useSyncExternalStore
를 반환하는 훅이다. useSyncExternalStore
은 간단하게 말해 리액트와 의존성이 없는 외부 스토어의 값을 감지하여 렌더링을 할 수 있게 하게 한다. 여기서는 {key, page content}
를 관리하는 Map
을 구독하여 렌더링과 연결할 것이다.
이제 contentCache
를 구현하자.
contentCache
는 ObservableMap
의 인스턴스로, ObservableMap
은 Map
을 커스텀하면서 내부적으로 listeners
를 가진다.
인자로 listener
(여기서는 렌더링 트리거)를 받는 subscribe
를 만들고
set
, delete
는 페이지 변경이 있을 때마다 추가, 삭제 하면서 emitChange
를 호출한다.
그리고 emitChange
는 listeners
를 순회하며 등록된 렌더링 트리거를 호출하게 되고, 리렌더링이 발생하게 된다.
그럼, UI를 받아오는 index.js
를 수정하면 된다.
처음 진입했을 때는 key
가 없을 것이다. key
를 만들고 contentCache
에 등록하자.
브라우저 히스토리의 state에 key가 있는지 보고 없으면 replaceState
로 key를 등록한다.
브라우저 히스토리에 잘 저장되었다.
그리고 그 key와 초기 UI를 contentCache
에 추가해주면 초기 설정은 완료다. 이제 브라우저 히스토리 이벤트가 발생했을 때, 캐시를 교체하자
현재 popState
이벤트 로직이다. popState
가 일어날 때마다 새로운 nextLocation
(path param)을 가져와 data fetching으로 UI를 가져온다.
이제 window.history.state?.key
에 해당하는 UI가 있는지를 확인할 것이다.
키가 없다면 새로 페칭을 하고 캐시에 저장한다.
그리고 키를 상태 관리한다.
root UI는 contentCache
에서 관리하니까 더 이상 contentPromise
를 상태 관리하지 않아도 된다. 과감하게 제거하고
캐시에 있는 UI를 렌더링하면 된다.
현재 navigate
로직이다. replaceState, pushState할 때 현재 페이지의 키를 브라우저 히스토리에 추가해야한다.
크게 달라지는 것은 없고 key를 새로 생성하여 브라우저 state에 추가한다.
popState와 마찬가지로 cache와 key를 업데이트하면 끝이다.
뒤로가기/앞으로가기를 해도 전체 페이지 새로고침없이 기존의 캐시된 UI를 사용하는 것을 확인할 수 있다!
이로써 SPA 기반 RSC를 완성했다. 서버 컴포넌트와 클라이언트 컴포넌트의 조합, 클라이언트 라우팅.. 갈수록 리액트가 UI에 관해 디테일해진다는 느낌을 받았다. 어디까지 갈 건가 리액트..
참고 : epic-react