5 분 소요

TIL

과제 전형 진행

구현

recoil 무한 스크롤

무한 스크롤

intersectionObserver

// Main.jsx (page)
useEffect(() => {
  const onIntersect = async ([entry], observer) => {
    if (entry.isIntersecting) {
      observer.unobserve(entry.target);
      setPage((prev) => prev + 1); // 여기
      observer.observe(entry.target);
    }
  };

  let observer;
  if (target) {
    observer = new IntersectionObserver(onIntersect, {
      threshold: 0.4,
    });
    observer.observe(target);
  }
  return () => observer && observer.disconnect();
}, [target]);

일단 무한 스크롤은 다음과 같이 구현하였다.
그래서 핵심은 “여기”(= target과 viewport가 교차하는 시점) 자리에 페이지를 하나씩 올려서 다음 페이지를 가져오도록 하고 싶었다.

그래서 page라는 state가 필요했다.

처음에는 그냥 local state로 page를 두었다.
그러다보니 컴포넌트가 언마운트되고 다시 돌아왔을 때 page 정보를 잃게 되니 전역 상태로 관리하여 저장해야겠다고 생각했다.

const [page, setPage] = useRecoilState(pageState);

따라서 setPage 즉 전역 상태를 변경하는 set 함수가 intersect마다 동작하도록 했다.

data fetching

page를 1씩 증가해주면 알아서 그 다음 페이지를 로드해서 가져와야 한다.

전역 상태 page가 바뀜에 따라 데이터를 어떻게 불러올 수 있을까를 많이 고민했던 것 같다.

// state.js (recoil)
export const getMoviesSelector = selectorFamily({
  key: "movie/get",
  get:
    (word) =>
    async ({ get }) => {
      try {
        const page = get(pageState);
        const { data } = await API.get("", {
          params: { s: word, page },
        });
        return data;
      } catch (err) {
        console.error(err);
      }
    },
});

일단 데이터를 fetching하는 함수는 위와 같다.
처음에 word = '', page = 1일 때 한 번 fetching이 될 것이다. (가장 처음)
그 다음 검색어를 입력해서 word가 변했을 때 또 불러와야 한다.

그것을 다음과 같이 구현했다.

// Main.jsx (page)
const [searchText, setSearchText] = useRecoilState(searchTextState);
const moviesLoadable = useRecoilValueLoadable(
  getMoviesSelector(searchText, page)
);

searchText를 전역 상태로 두고, 검색어를 submit할 때 그 단어로 set을 해준다.
그러면 searchText가 변경될 때 getMoviesSelector도 동작하여 새로운 데이터를 주게 될 것 이다.

page도 똑같이 적용될 것이다. getMoviesSelector가 동작하는 시점은 page는 intersect마다인 것이고, 검색어는 submit할 때만 인 것이다.

items slicing & recoil loadable

그래서 이제 받은 데이터를 어떻게 잘라서 어떻게 붙여서 보여줄 것인가를 생각해야한다.

이 부분에서도 좀 헤맸는데, redux였으면 reducer 안에서 data(item)들을 잘라서 dispatching해서 사용하면 될 것 같았지만
api 자체가 page로 나누어져 있어서 그렇기도 하고, recoil은 어떻게 구현해야 하는 것인지 생각해야 했던 것 같다.

먼저 loadable은 state와 contents를 가지고 있는 객체이다.

state : hasValue , hasError , loading 상태를 가짐

contents : atom이나 contents의 값을 나타내며, 상태에 따라 다른 값을 가지고 있다.
          hasValue : value
          hasError : Error 객체
          loading : Promise

따라서

// Main.jsx (page)
const moviesLoadable = useRecoilValueLoadable(
  getMoviesSelector(searchText, page)
);

getMoviesSelector로 데이터를 요청해서 받아올 때까지 state를 이용해서
loading일 때 loader 컴포넌트를 나타내고,
hasValue이면 받은 데이터를 가지고 리스트를 나타내면 될 것이다.

// Main.jsx (page)
useEffect(() => {
  if (!moviesLoadable?.contents.Search?.length) return;

  setPageItem((prev) =>
    moviesLoadable?.state === "hasValue"
      ? [...prev, ...moviesLoadable.contents.Search]
      : []
  );
  setTotalPage(
    moviesLoadable?.state === "hasValue"
      ? Math.ceil(moviesLoadable?.contents.totalPage / 10)
      : 1
  );
}, [moviesLoadable]);

useEffect(() => {
  return () => resetItemsAndPage();
}, []);

pageItem 또한 전역 상태로 관리해서 현재 보여주고 있는 리스트를 저장하고 있는다.
그 다음 setPageItem 함수를 이용해서 hasValue 즉, 데이터가 도착했을 때 그때 받은 데이터(loadable.contents)안에 리스트를 기존 pageItem 리스트 뒤에 붙여준다.
이렇게 되면 처음 10개 그 뒤에 방금 불러온 데이터 10개가 붙는 꼴이 되겠다.

그 다음 totalPage. 사실 이부분은 좀 마음에 들지 않는다.
totalPage는 같은 검색어 데이터를 불러올 때는 계속 같을 텐데 여기서 주기적으로 set을 해주어야 하는 것이 마음에 들지 않는다.
리팩토링은 필요할 것 같으나 일단은 패스하는 것으로.
어쨌든 totalPage는 가장 뒤 페이지의 수를 저장했다가 끝에 다다랐을 때 데이터를 더이상 fetching 하지 못하도록 div를 숨기는 용도로 사용했다.

// Main.jsx (page)
// return
{
  pageItem?.length > 0 &&
    page !== totalPage &&
    moviesLoadable.contents.Response && <S.TargetDiv ref={setTarget} />;
}

추가로, loadable을 사용한 이유가 하나 더 있는데, 바로 Suspense의 UX 때문이다.

suspense 컴포넌트를 통해 fallback 함수에 컴포넌트를 넣어서 loading 화면을 간단하게 나타낼 수 있었다.
하지만 문제는 내가 loading 화면을 띄우고 싶은 것은 Main page 안에서도 CardsContainer 이다.

Suspense로 하게 되면 전체 화면이 loading 화면으로 대체가 되므로 UX 적으로 좋아보이지 않았다.
loadable로 사용하니 국소적으로 loading을 나타낼 수 있었기에 아주 적합했다.

// Main.jsx (page)
// return
<S.CardsContainer>
  {moviesLoadable.state === "hasValue" ? (
    pageItem.length > 0 ? (
      pageItem.map((movie) => (
        <MovieCard key={movie.imdbID} movieInfo={movie} />
      ))
    ) : (
      <NoList msg="검색 결과가 없습니다." />
    )
  ) : moviesLoadable.state === "loading" ? (
    <div>Loading...</div>
  ) : (
    console.error(moviesLoadable.contents)
  )}
</S.CardsContainer>

이렇게 하면 다음과 같이 데이터를 무한 스크롤을 이용하여 불러올 수 있게 되었다.

이미지

문제가 생겼다! page가 증가한 그대로 전역 상태에 저장되어 있어서
다른 페이지를 갔다 왔을 때, 증가한 page에서 부터 리스트를 나열한다. (이전 것들은 생략이 되어 버린다.)

그래서 reset을 해주어야 겠다고 생각했다.

무한 스크롤 추가 작업

이럴 때 쓰는 것이 useResetRecoilState이다.

이 hook을 쓰는 것과 그냥 set 함수를 이용해서 원래 default 값으로 돌리는 것과 어떤 차이가 있냐면,
hook을 사용하면 렌더링을 하지 않고 default 값으로 돌아간다!

// Main.jsx (page)
const [page, setPage] = useRecoilState(pageState);
const resetPageNum = useResetRecoilState(pageState); // page를 reset
const moviesLoadable = useRecoilValueLoadable(
  getMoviesSelector(searchText, page)
);
const [pageItem, setPageItem] = useRecoilState(pageItemState);
const resetItems = useResetRecoilState(pageItemState); // 리스트 데이터를 reset
// Main.jsx (page)
const resetItemsAndPage = useCallback(() => {
  resetItems();
  resetPageNum();
}, []);

const handleSubmit = (e) => {
  e.preventDefault();
  if (!text) return;
  resetItemsAndPage();
  setSearchText(text);
  scrollToTop();
  setText("");
};

useEffect(() => {
  return () => resetItemsAndPage();
}, []);

그래서 나열되어 있던 items과 fetching 하고 있던 page 를 default 상태로 돌리는 함수를 만든다.
이 cleanup이 필요할 때가 다른 페이지로 넘어갈 때 즉, 현재 Main 페이지 컴포넌트가 언마운트될 때 기존 데이터를 없애준다.
또한, 검색어를 submit 할 때 기존 데이터가 남아있지 않고, 새로운 데이터를 바로 보여주기 위해 이 때도 cleanup을 해준다.

이렇게 하면 잘 적용이 되는 것을 볼 수 있다.

이미지

회고 (TIL)

2022.03.27 Daily 회고

✏오늘 한 일

  • 과제 전형 진행
    • modal
    • localStorage
    • recoil refactoring

⁉느낀 점

recoil 간단한데 생각보다 어렵다. 계속 redux의 패턴을 생각하면서 구현하려다보니 왜 이게 안되지하면서 이상한 것을 검색했던 것 같다.
새로운 기술을 배울 때는 기존에 쓰던 기술과 비교해도 좋지만 새로운 패러다임일 수도 있다는 것을 명심하자.

처음 볼 때는 도대체 이거 왜 써 했는데, 확실히 redux로 구현했었으면 redux 파일에 코드가 한 가득일 것들이 엄청 간결해지는 것을 볼 수 있다.
특히 간단하게 전역 상태를 공유할 때 (modal open/close boolean) 진짜 장황한 action, reducer 없이 거의 한 두줄안에 작성할 수 있다는 것이 매우 흥미로웠다.

util이나 custom hook을 이런식으로 빼는 게 맞는 건가 싶다! best practice를 알 수 있을까

🎃현재 나의 상태

제출할 수 있을 정도로는 구현이 끝났다. 내일이나 모레 D&D 해봐야지

역시 공부보다는 그냥 코딩이 더 재미있다ㅋㅋㅋ
공부도 해야하는데..


댓글남기기