[모투] Recoil, selectorFamily를 활용한 렌더링 최적화

Chanyoung Park·2024년 5월 21일
0

들어가며

배경

  • 특정Tab을 클릭하면, Accordion 컴포넌트의 open state값을 변경시켜 Accordion을 열고자 했다.
  • 아래 이미지를 보면, Tab제목이나 Accordion버튼을 클릭할 때마다, 모든 컴포넌트가 재렌더링되는 문제가 있었다.

소스구조

  1. 바탕이 되는 컴포넌트 TabScrolluseTabScroll(hooks)에서 Recoil데이터를 선언하고, Tab제목컴포넌트와 Accordion컴포넌트를 하위 자식으로 가진다.
// TabScroll.tsx
const TabScroll = ({
  groupLikeStockList,
}: {
  groupLikeStockList: TGroupLikeStockInfo[];
}) => {
  // [registryRef]의 tabIndex로 스크롤될 컴포넌트의 ref배열에 등록한다.
  // [handleScroll]로 지정된 컴포넌트 클릭시, ref배열의 위치로 스크롤 및 ref컴포넌트의 Accordion을 열어준다.
  const { registryRef, handleScroll, headerRef } = useTabScroll({
    length: groupLikeStockList.length,
  });
  ...
  return (
    ...
    // Tab제목 컴포넌트
    {groupLikeStockList.map((group, idx) => {
		return (
			<Button key={idx} tabIndex={idx} onClick={handleScroll}>
				{group.groupName}
			</Button>
		);
	})}
    ...
    // Accordion컴포넌트
	{groupLikeStockList.map((group, idx) => {
        return (
          <div key={idx} tabIndex={idx} ref={registryRef}>
            <Section.Accordion
              index={idx}
              title={group.groupName}
              noContent={<Button primary>주식 추가하기</Button>}
            >
              {group.likeStockInfoList.length &&
                group.likeStockInfoList.map((likeStock, idx) => {
                  return (
                    <div
                      key={idx}
                      className="flex items-center justify-between"
                    >
                      <p>
                        {likeStock.name} ({likeStock.stockId})
                      </p>
                      <Button primary>BUY</Button>
                    </div>
                  );
                })}
            </Section.Accordion>
          </div>
        );
      })}
  )
}
// useTabScroll.ts | hooks함수
export const useTabScroll = ({ length }: { length: number }) => {
	const [isOpenTabList, setIsOpenTabList] = useRecoilState(tabOpenStateList);

	// 버튼 클릭시, ref에 등록된 element로 스크롤 이동
	const handleScroll = (e: MouseEvent<HTMLButtonElement>) => {
    	const tabIndex = e.currentTarget.tabIndex;
			...
			setIsOpenTabList(() => {
				const newState = [...isOpenTabList];
				newState[tabIndex] = true;
				return newState;
			});
        }
  };
})

// 
  1. recoil의 atom에는 tabOpenStateList가 등록되어있다.
export const tabOpenStateList = atom<boolean[]>({
  key: "tabOpenStateList",
  default: [],
});

문제원인

  • Tab을 클릭할 때마다 실행되는 setIsOpenTabList부분에서 recoil의 전체 상태값이 변경되어, 모든 컴포넌트가 재렌더링된다고 생각되었다.
setIsOpenTabList(() => {
	const newState = [...isOpenTabList];
	newState[tabIndex] = true;
	return newState;
});
  • 그래서 이러한 isOpenTabList state배열을 잘게 잘라서 사용할 수 있도록 selectorFamily를 활용하였다.

적용

// atoms/tab.ts
import { DefaultValue, atom, selectorFamily } from "recoil";

export const tabOpenStateList = atom<boolean[]>({
  key: "tabOpenStateList",
  default: [],
});

export const tabOpenState = selectorFamily({
  key: "tabOpenState",
  get:
    (tabIndex: number) =>
    ({ get }) => {
      return get(tabOpenStateList)[tabIndex];
    },
  set:
    (tabIndex: number) =>
    ({ set }, newValue: boolean | DefaultValue) => {
      set(tabOpenStateList, (prev) => {
        const newState = [...prev];
        if (!(newValue instanceof DefaultValue)) {
          newState[tabIndex] = newValue;
        }
        return newState;
      });
    },
});
  • 이처럼 TabIndex값에 따라 tabOpenStateList에서 특정요소만 get/set할 수 있도록 설정했다.

활용

// useTabScroll.ts
  const setOpenList = Array(length)
    .fill(false)
    .map((_, idx) => useSetRecoilState(tabOpenState(idx)));
...

// 버튼 클릭시, ref에 등록된 element로 스크롤 이동
  const handleScroll = (e: MouseEvent<HTMLButtonElement>) => {
    const tabIndex = e.currentTarget.tabIndex;
    if (tabBodyRef.current) {
      (tabBodyRef.current[tabIndex] as HTMLDivElement).scrollIntoView({
        behavior: "smooth",
        block: "start",
        inline: "nearest",
      });
      setOpenList[tabIndex](true);
    }
  };
  • 위와 같이, tabOpenState selectorFamily를 배열의 수만큼 setOpenList변수에 미리 할당해둔다.

    이처럼 적용한 이유!
    handleScroll함수가 실행될 때, 비로소 openState값이 바뀌어야 하기에 useSetRecoilState를 함수내부에서 사용할 수 있도록 미리 선언해주어야 했다.

    React hooks의 선언 규칙은 항상 최상단에서 이루어져야 하기에, 위와 같이 setOpenList에 미리 담아주었다.

  • 자주 접하는 에러...
  • 이처럼 적용하고, Accordion 컴포넌트에서도 index에 맞는 selector를 가져오도록 작성해주었다.

그 결과!

  • 다음과 같이, tab클릭 & accordion버튼 클릭 시에도, 해당 컴포넌트만 re-render되도록 최적화할 수 있었다!!

수정된 소스코드

https://github.com/Dolphin-PC/motoo/commit/500f93424fbf21b188545aa5dd7745d6f57f21ed

2024.05.23 수정

  • selectorFamily -> atomFamily로 수정
  • 처음에는 atom으로 선언된 배열에 대해서, get/set을 할 수 있는 selectorFamily를 사용했다.
  • 하지만, selectorFamily와 atomFamily의 기능이 거의 유사한 것을 알 수 있었고, 이는 사용목적에 따라 구분되어 있다는 것을 알 수 있었다.
    • atomFamily : 동적인 키를 가진 atom의 집합 (동일한 상태를 공유해야 하는 여러 컴포넌트에 용이)
    • selectorFamily : 동적인 키를 가진 selector의 집합 (atom값에 계산을 하거나, 비동기작업을 수행하는 데 사용)
  • 이를 보았을 때, 내 상황에서는 atomFamily가 적합하다고 생각하여 코드를 리팩토링했다 (더 간결해졌다!)
// atoms/tab.ts

export const tabOpenStateList = atomFamily<boolean, number>({
  key: "openList",
  default: () => false,
});
// atom관련한 코드가 이렇게 짧아졌다.

3줄 요약

  • 특정 Tab을 클릭하면 Accordion이 열리게 하고 싶어, recoil을 사용했다.
  • 하지만 atom(배열)로 OpenState를 관리하고 있어, 특정요소가 update되면 전체 페이지가 re-render되는 문제 발생
  • 이를 atomFamily로 수정하여, OpenState(atom 집합)로 특정 컴포넌트만 re-render되도록 수정
profile
더 나은 개발경험을 생각하는, 프론트엔드 개발자입니다.

0개의 댓글