github issues reader를 구현하면서 개인적으로 아쉬웠던 부분을 보완하는 작업을 했습니다.
배포 링크
소스코드(Github)
이전 작업 내용 블로깅
리스트 페이지 → 디테일 페이지 → (뒤로가기) 리스트 페이지 순서로 이동했을 때 리스트의 스크롤 위치가 유지되지 않는 상황이었습니다.
리스트나 페이지네이션을 사용할 땐 마지막에 봤던 위치가 저장되는 것이 유저 경험에 중요하다고 생각하기 때문에 꼭 고쳐야겠다고 생각했었는데요.
스크롤이 유지되지 않는 이유는 뒤로가기로 인해 ListPage가 다시 마운트되면, 서버에게 첫 번째 페이지를 요청하여 렌더링하기 때문이었습니다. 첫 번째 페이지만을 다시 보여주기 때문에 무한스크롤을 통해 추가적으로 요청했던 부분은 다시 스크롤을 내려야 받아볼 수 있는 것이죠.
따라서 ListPage에서 이전에 요청하여 렌더링했던 내용과 스크롤 위치를 캐싱하여, 재접속 시 캐싱한 내용을 모두 렌더링하여 그 지점의 스크롤 위치로 이동하도록 변경했습니다.
스크롤 위치를 기억하는 방법은 라우팅에 사용한 react-router
라이브러리에서 ScrollRestoration
컴포넌트를 제공하고 있기 때문에 쉽게 구현할 수 있었습니다.
사용 방법은 라우트 컴포넌트에 ScrollRestoration 컴포넌트를 불러와 사용하면 됩니다.
// App.tsx
function App() {
return (
<>
<Header />
<Main>
<Outlet />
</Main>
<ScrollRestoration />
</>
);
}
스크롤을 많이 내린 상태여서 100번째 항목 즘의 스크롤 위치를 기억하고 있는 상황인데, 재접속으로 30개 정도 보여주는 첫 번째 페이지만을 다시 렌더링한다면 제일 밑의 위치가 30번이기 때문에 기억한 스크롤이 의미가 없어집니다.
따라서 100번째 항목이 보일 수 있도록 전에 요청했던 항목들을 캐싱해야 합니다.
이 프로젝트는 React-Query를 사용하지 않았기 때문에 캐싱은 Recoil
을 사용했습니다.
이전 코드에서 useReducer로 묶어주었던 상태들을 recoil로 옮겨준게 전부입니다!
type IssuesState = {
issues: IssuesResponseData | undefined;
isFetching: boolean;
hasNextPage: boolean;
};
export const issuesState = atom<IssuesState>({
key: 'issuesState',
default: {
issues: undefined,
isFetching: false,
hasNextPage: false,
},
});
이렇게 렌더링했던 데이터들을 캐싱하여 사용함으로써 스크롤의 위치도 유지되고 뒤로가기로 리스트 페이지에 접속했을 때 발생했던 api 요청도 보내지 않게 되었습니다.
다만, 새로고침이 발생하기 전까지 캐싱한 내용들이 계속 전역 상태로 남아있기 때문에 이 데이터들을 언제 지워야 하는가 라는 문제점이 있습니다. 캐싱은 expire time을 고려해야 한다는 점이 참 어려운 것 같아요. 고민해봤는데 새로고침하면 날아가니까 굳이 삭제하지 않아도 괜찮을 것 같긴 하네요..ㅎㅎ
제일 저를 괴롭게 했던 부분입니다.
데이터가 없을 때 보여주려고 한 컴포넌트(’이슈가 없습니다’)가 아주 잠깐 노출되는 현상이 있었습니다.
페이지가 마운트되면 useEffect에서 비동기적으로 데이터를 받아오기 때문에 어쩔 수 없는 상황이었는데요.. 그래도 한 번 최대한 해결해보려 했습니다.
일단 사건의 배경을 설명해보겠습니다.
issue들을 페칭하는 로직은 useIssues 훅 내부에 있고, 페칭하기 전 issues는 undefined입니다.
// useIssues.tsx
const useIssues = () => {
const [{ issues, isLoading, hasNextPage }, setIssuesState] = useRecoilState(issuesState);
const [pageNumber, setPageNumber] = useState(1);
const fetchIssues = () => { /* ... */ };
useEffect(() => {
fetchIssues(1);
}, [fetchIssues]);
return { issues, isLoading, hasNextPage, fetchNextPage };
};
undefined인 issues를 가지고 먼저 return문을 실행하게 되므로 EmptyList가 노출된 다음 effect가 실행됩니다.
따라서 effect가 실행되고 상태가 변경되어 리렌더링이 발생하기 전까지의 그 짧은 틈은 EmptyList가 자리를 차지하게 됩니다.
// IssueList.tsx
const IssueList = () => {
const { issues, isFetching, hasNextPage, fetchNextPage } = useIssues();
const [observerRef] = useIntersectionObserver({ threshold: 0.1 }, fetchNextPage);
return (
<>
{issues.length ? (
<>
<Ul>
{issues.map((issue, order) => {
const parsedIssue = parseIssue(issue);
return (
<Fragment key={parsedIssue.issueId}>
<IssueListItem issue={parsedIssue} />
{(order + 1) % PER_LIST === 0 && <Ad />}
</Fragment>
);
})}
</Ul>
{!isFetching && hasNextPage && <div ref={observerRef}></div>}
{isFetching && <Loading />}
</>
) : (
<EmptyList />
)}
</>
);
};
EmptyList의 노출 없이 마운트 순간부터 effect가 완료되고 리렌더링되기까지 로딩 컴포넌트를 보여줄 수 있는 방법에 대해 고민해 봤습니다.
api 요청을 보내기 전까지는 issues가 undefined라는 점을 활용해, issues가 undefined면 로딩 화면을 띄우도록 했습니다.
(실은 issues의 초기값을 빈 배열로 해뒀어서 여기까지 생각해 내는데 적지 않은 시간이 걸렸습니다..ㅎㅎ ㅠ)
// useIssues.tsx
const useIssues = () => {
const [{ issues, isLoading, hasNextPage }, setIssuesState] = useRecoilState(issuesState);
const [pageNumber, setPageNumber] = useState(1);
const isFirstLoad = issues === undefined;
isFetching의 초깃값을 true로 줘버릴까라는 많은 유혹이 있었는데요..
아무래도 요청을 시작하기 전부터 로딩중이라는 상태를 갖고 있는 건 아닌 것 같아서 새로운 변수를 만들기로 선택했습니다.
따라서 IssueList 컴포넌트는 다음과 같이 변경했습니다. firstLoad라면 바로 스켈레톤을 보여줍니다.
const IssueList = () => {
const { issues, isFirstLoad, isFetching, hasNextPage, fetchNextPage } = useIssues();
const [observerRef] = useIntersectionObserver({ threshold: 0.1 }, fetchNextPage);
if (isFirstLoad) return <ListSkeleton />;
이렇게 작성하면 useEffect의 콜백 함수가 실행되기 전 그 사이에도 ListSkeleton으로 로딩중임을 보여줄 수 있습니다.
이 사소한 걸 정말 오랫동안 고민해서 고쳤는데 좋은 방법인지는 잘 모르겠습니다. 만약 서버에서 데이터가 없을 때 빈 배열로 주지 않고 undefined로 준다면 스켈레톤만 계속 보여주게 될테니까요..ㅎ (지금은 빈 배열로 주는 상황입니다.)
isFetching을 true로 초기화해두기, 지금처럼 변수 하나 더 만들기 이외에 더 좋은 방법이 있다면 공유 부탁드립니다 ㅠㅠ
참고로 스켈레톤은 라이브러리 없이 간단하게 직접 구현해 봤습니다.
width
와 height
를 받고, 혹시 커스텀 스타일을 넣고 싶어질 수도 있을 것 같아 div의 어트리뷰트를 더 받을 수 있게 했습니다.
const Skeleton = ({ width, height, ...props }: Props) => {
return <SkeletonBox width={width} height={height} {...props} />;
};
const SkeletonBox = styled.div<Props>`
width: ${({ width }) => width};
height: ${({ height }) => height};
background-color: #e8e8e8;
border-radius: 2px;
animation: loading 2.5s infinite;
`;
애니메이션은 간단하게 div의 투명도를 변경하는 식으로 만들어 로딩 중임을 좀 더 잘 표현하게 했습니다.
/* animation.css */
@keyframes loading {
0% {
opacity: 1;
}
50% {
opacity: 0.45;
}
100% {
opacity: 1;
}
}
이 스켈레톤 하나를 입맛대로 조합해서 전체 스켈레톤을 만들면 됩니다. 이런 식으로요..ㅎ
<LiContainer>
<Skeleton width={'100%'} height={'36px'} />
<Skeleton width={'40%'} height={'24px'} />
</LiContainer>