고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

내 입맛에 맞게 useList를 구현하기

고윤태 2023. 5. 10. 18:08

안녕하세요 이번에 소개드릴 내용은 제가 이번에 구현한 useList(가명)에 대해 소개 드리려고 합니다.
제가 어떠한 상황에서 useList를 구현하기로 마음을 먹었고
어떤 식으로 구현을 했는지에 대해 알려드리면 좋을 거 같다고 판단이 되어서 한 번 작성해 봅니다.

개발 환경

React-native + TypeScript로 진행되었습니다.


구현하게 된 계기

ex

위 사진과 같은 화면 구현의 업무를 맡았습니다.
탭 형식으로 이루어졌으며 선택된 카테고리의 리스트를 보여주는 화면이었습니다.

어떻게 예쁘게 구현할까 고민을 한 후

custom hook으로 구현해야겠다 마음을 먹고 바로 기능들을 추출해봤습니다.


기능 추출

  • A Tab
    • 리스트 불러오기
    • 인피니티 스크롤
    • 인증하지 않은 유저라면 인피니티 스크롤 X, 리스트 하단에 인증을 유도하는 문구 노출 시키기
  • B Tab
    • 리스트 불러오기
    • 인피니티 스크롤
    • 인증하지 않은 유저라면 인피니티 스크롤 X, 리스트 하단에 인증을 유도하는 문구 노출 시키기
    • 즐겨찾기 추가 및 삭제
  • C Tab
    • 리스트 불러오기
    • 인피니티 스크롤
    • 인증하지 않은 유저라면 인피니티 스크롤 X, 리스트 하단에 인증을 유도하는 문구 노출 시키기
  • D Tab(즐겨찾기 리스트 탭)
    • 리스트 불러오기
    • 인피니티 스크롤
    • 즐겨찾기 삭제

제가 이 기능 추출을 작성하는데도 CTRL+C, CTRL+V를 하게 될 정도로 공통된 기능이 많았습니다.

이것을 바탕으로 개발에 들어갔습니다.

 


구현

Parameter

제가 생각한 필요한 Parameter 값들은 이렇습니다.

type Parameter = {
  URL: string;
  flatListRef?: React.RefObject<FlatList<any>>;
  generateQueryString: () => string;
  errorCallback?: () => void;
};
  • URL : API BASE URL이 아닌 BASE_URL/ <= 여기 붙을 URL 입니다.
    • 예시 : BASE_URL/fruit
    • 예시 : BASE_URL/animal
  • flatListRef : ref를 받은 이유는 스크롤 위치를 컨트롤하기 위해서 받았습니다.
    • 예시 : 필터를 적용하여 검색한 경우 스크롤 위치를 맨 위로 올리기 위해
  • generateQueryString : list 요청에 필요한 쿼리스트링을 반환해주는 함수를 위해서 받았습니다.
  • errorCallback : API 요청 시 error가 발생한 경우 처리를 위해 받았습니다.
    • 처리가 모두 같다면 필요가 없었지만 특정한 곳에서는 error 발생 시 다른 처리를 해줘야했습니다.

custom Hook 선언

const useList = <T extends { id: string; isFavorite?: boolean }>(parameter: Parameter) => {
};

export default

저 같은 경우 이런 식으로 Type을 지정하여 선언했습니다.
제네릭으로 지정한 이유는 API를 통해 받은 list response가 똑같지 않기 때문입니다.
그 후 extends를 통해 위에서 작성한 즐겨찾기 기능을 구현하기 위해 id와 isFavorite 을 상속 시켰습니다.


state와 변수 선언

  const { flatListRef, URL, errorCallback, generateQueryString } = parameter;

  const user = useRecoilValue(userState);
  const [isLoading, setIsLoading] = useRecoilState(loadState);

  const isUserVerified = !!user;

  const [offset, setOffset] = useState(0);
  const [list, setList] = useState<T[]>([]);
  • 첫 번째로 구조 분해 할당을 이용하여 props를 구조 분해 할당을 합니다.
  • user 정보 같은 경우 현재 recoil을 통해 가져옵니다.
  • recoil로 관리하는 로딩 상태도 가져옵니다.
  • isUserVerified는 위에서 작성했던 "인증하지 않은 유저라면 인피니티 스크롤 X, 리스트 하단에 인증을 유도하는 문구 노출시키기" 로직을 반영하기 위해 할당해놨습니다.
  • offset은 인피니티 스크롤의 페이지입니다.
  • list는 API를 통해 받은 list입니다.

fetch 함수

  const fetch = async (shouldScrollToTop = false) => {
    if (isLoading) {
      return;
    }

    const requestOffset = shouldScrollToTop ? 0 : offset;
    const query = generateQueryString();

    setIsLoading(true);

    try {
      const response = await axios.get(`${BASE_URL}${URL}?${query}&offset=${requestOffset}`);

      if (shouldScrollToTop) {
        setList(response.data.list);
        flatListRef?.current?.scrollToOffset({
          animated: true,
          offset: 0,
        });
      } else {
        setList(prevList => [...prevList, ...response.data.list]);
      }

      setOffset(requestOffset + LIMIT);
    } catch (err) {
      console.log(err);
      errorCallback?.();
    } finally {
      setIsLoading(false);
    }
  };

Parameter인 shouldScrollToTop은 true라면 FlatList의 ScrollPostion을 0(맨 위)로 올려주기 위해 사용됩니다.

이 코드에서 눈여겨보면 좋은 포인트는 2개인 것 같습니다.

requestOffset : shouldScrollToTop의 값에 따라서 0 또는 offset을 보내고 있습니다. 여기서 만약 바로 setState로 offset 값을 바꾼다면 useState 특성상 바뀐 값을 사용할 수 없어 useEffect로 처리하는 과정이 생겨버립니다.
이러한 이유로 요청 후 값을 set 하고 있습니다.
shouldScrollToTop : 값에 따라서 true 면 값을 할당 후 스크롤 위치 변경 값이 false 면 기존 값에 이어 붙이기를 하고 있습니다.
이러한 이유는
true인 경우사용자가 검색 옵션을 바꾼 경우입니다.예시(정렬 순 변경) 이런 경우 그에 맞는 데이터만 보여야 하기 때문에 값을
할당해버립니다.
false인 경우는 사용자가 인피니티 스크롤을 통해 fetch 요청이 들어온 경우입니다. 이런 경우는 위로 다시 올렸을 때 이전 데이터가 남아있어야 하기 때문에 이어 붙입니다.

onEndReachedHandler 함수

  const onEndReachedHandler = () => {
    if (isUserVerified) {
      fetch();
    }
  };

이 함수는 간단한 편입니다.

React-Native에서 List를 구현할 때 FlatList라는 React-Native에서 제공해 주는 컴포넌트를 흔히 사용하게 됩니다
스크롤이 일정 이상까지 내려왔을  호출해 주는 함수를
onEndReached라는 props로 받고 있습니다.

 

FlatList · React Native

A performant interface for rendering basic, flat lists, supporting the most handy features:

reactnative.dev

출처:https://reactnative.dev/docs/flatlist

 

인피니티 스크롤 구현을 위해 일정 부분까지 내려오면 fetch 함수를 요청하도록 하였습니다만
조건문이 하나 있습니다.
isUserVerified 가 true 면 요청해라 그 이유는 아까 위에서 작성한 "인증하지 않은 유저라면 인피니티 스크롤 X"라는 조건이 있었기 때문입니다.

 


update, toggleFavorite함수


updateFavorite

 const updateFavorite = async (isFavorite: boolean, id: string) => {
    //API 요청
 };

코드를 생략했지만 단순하게 API 요청을 보내는 함수입니다.

 

toggleFavorite

  const toggleFavorite = async (target: T) => {
    if (!user?.uid) {
      return;
    }

    setIsLoading(true);

    try {
      const cloneList = [...list];
      const targetIndex: number = cloneList.findIndex(item => item.id === target.id);

      const isFavorite: boolean = !!list[targetIndex].isFavorite;
      
      await updateFavorite(!isFavorite, target.id);

      if (targetIndex > -1) {
        cloneList[targetIndex].isFavorite = !isFavorite;
        setList(cloneList as T[]);
      }
    } catch (err) {
      console.error(err);
    } finally {
      setIsLoading(false);
    }
  };

 

현재 isFavorite 값을 NOT 연산 처리를 하여 API 요청을 보낸 뒤 list에서 제거하는 로직입니다.


최종 구현 코드

type Parameter = {
  URL: string;
  flatListRef?: React.RefObject<FlatList<any>>;
  generateQueryString: () => string;
  errorCallback?: () => void;
};

const BASE_URL = API BASE URL

const useList = <T extends { id: string; isFavorite?: boolean }>(parameter: Parameter) => {
  const { flatListRef, URL, errorCallback, generateQueryString } = parameter;

  const user = useRecoilValue(userState);
  const [isLoading, setIsLoading] = useRecoilState(loadState);

  const isUserVerified = !!user;

  const [offset, setOffset] = useState(0);
  const [list, setList] = useState<T[]>([]);

  const fetch = async (shouldScrollToTop = false) => {
    if (isLoading) {
      return;
    }

    const requestOffset = shouldScrollToTop ? 0 : offset;
    const query = generateQueryString();

    setIsLoading(true);

    try {
      const response = await axios.get(`${BASE_URL}${URL}?${query}&offset=${requestOffset}`);

      if (shouldScrollToTop) {
        setList(response.data.list);
        flatListRef?.current?.scrollToOffset({
          animated: true,
          offset: 0,
        });
      } else {
        setList(prevList => [...prevList, ...response.data.list]);
      }

      setOffset(requestOffset + LIMIT);
    } catch (err) {
      console.log(err);
      errorCallback?.();
    } finally {
      setIsLoading(false);
    }
  };

  const onEndReachedHandler = () => {
    if (isUserVerified) {
      fetch();
    }
  };

  const updateFavorite = async (isFavorite: boolean, id: string) => {
    //API 요청
  };

  const toggleFavorite = async (target: T) => {
    if (!user?.uid) {
      return;
    }

    setIsLoading(true);

    try {
      const cloneList = [...list];
      const targetIndex: number = cloneList.findIndex(item => item.id === target.id);

      const isFavorite: boolean = !!list[targetIndex].isFavorite;
      
      await updateFavorite(!isFavorite, target.id);

      if (targetIndex > -1) {
        cloneList[targetIndex].isFavorite = !isFavorite;
        setList(cloneList as T[]);
      }
    } catch (err) {
      console.error(err);
    } finally {
      setIsLoading(false);
    }
  };

  return {
    fetch,
    onEndReachedHandler,
    list,
    toggleFavorite,
    isUserVerified,
  };
};

export default useList;

후기

제가 구현한 custom hook을 사용하여 반복도 최소화하고 이번에 작업한 feature에 useList를 사용하여 대부분의 로직을 처리할 수 있었습니다.

덕분에 유저들의 니즈를 맞추면서 깔끔한 로직이 탄생한 거 같습니다.


제가 비록 블로그 글을 몇 개 쓰지는 않았지만 이 정도 분량을 쓰게 될 줄을 몰랐었습니다.
혹시나 저와 비슷한 개발 고민을 가지고 뒤적뒤적 하시는 분들께 도움이 되었으면 하는 마음으로 끝까지 글을 작성했네요
읽어주셔서 감사합니다.

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

useDebounce를 활용하기  (1) 2023.06.07
React Native Chip을 구현해보자  (0) 2023.05.26
React Native 나만의 checkbox 구현하기 ✅  (0) 2023.05.16
React Native useModal 구현하기  (0) 2023.05.02
useCodePush 구현하기  (3) 2023.04.24