[React] You might not need an effect를 읽고 프로젝트 effect 수정하기

챌린지반에서 지원해 진행하게된 발표 자료다. 다른 아티클도 있었지만 이 내용을 선택한 이유는 지난번에 시간에 쫓겨 제대로 알아보지 못한 useEffect를 제대로 알아보고싶다는 생각이 들어서였다.

 

단순히 아티클 내용을 설명하는 것이 아니라, 공부하면서 막혔던 부분을 어떻게 해소했는지 / 그리고 이전에 진행한 메달 트래커 과제에서 사용한 useEffect들을 다시 살펴보고 수정하는 내용을 발표했다.

 

목차
1. 아티클 내용 중 이해가 어려웠던 부분과 해결 방법
2. 메달 트래커 과제에서 사용한 useEffect 돌아보기

 

useEffect

  • 컴포넌트가 렌더링된 후 실행할 동작을 설정할 수 있는 Hook
  • 의존성 배열을 이용해 특정 값이 변경됐을 때만 동작을 실행하도록 설정할 수 있음!

 

Effect가 필요없는 일반적인 경우

  1. 렌더링을 위해 데이터를 변환하는 경우
    • 변경된 state를 정렬해 보여주고 싶은 경우, effect가 다시 state를 변경한다면 정렬을 위해 렌더링 2회
    • (불필요한 렌더링 피하기 위해) 컴포넌트의 최상위 레벨에서 계산 가능하다면 Effect에서 처리하지 않기!
  2. 사용자 이벤트를 처리하는 경우
    • 이벤트 핸들러를 사용하는 것이 더 의도를 명확히 할 수 있음
    • 이벤트 핸들러는 바로 계산되지만, Effect는 렌더링 후 계산

 

어려웠던 부분

Effect 없이 일부 state 조정하기

  • items를 props로 전달받아 선택된 아이템을 selection에서 관리
  • items가 바뀔 때마다 selection을 null로 재설정

피해야할 방법

const List = ({items}) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);
  
  useEffect(() => {
    setSelection(null);
  }, [items]);
};

 

개선안 1

const List = ({items}) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);
  
  const [prevItems, setPrevItems] = useState(items);
  if(items !== prevItems) {
    setPrevItems(itmes);
    setSelection(null);
  }
};

 

🤯🤯🤯 state 변경이 일어나는 건 마찬가지인데 무슨 차이가 있다는 걸까??????

렌더링 과정에 대한 이해가 충분하지 않아 두 경우의 차이를 느끼기 어려웠다.직접 테스트 코드를 작성해 콘솔에 찍으며 렌더링 과정을 살펴보기로 했다.

function App() {
  const [items, setItems] = useState(0);
 
  return (
    <>
      { /* 생략 */}
      <List items={items} />
    </>
  );
}
 
function List({ items }) {
  console.log("List 렌더링");
  const [selection, setSelection] = useState(0);
  
  // useEffect 사용하는 경우
  useEffect(() => {
    console.log("useEffect 실행");
    setSelection(selection + 1);
  }, [items]);
 
  // 개선안 사용하는 경우
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    console.log("prevItem과 비교");
    setPrevItems(items);
    setSelection(selection + 1);
  }
 
  return (
    <>
      <div>selection {selection}</div>
      <ChildComponent selection={selection} />
    </>
  );
}
 
function ChildComponent({ selection }) {
  console.log("ChildComponent 렌더링");
 
  return <div>ChildComponent {selection}</div>;
}

 

 

useEffect 사용하는 경우

  • 리렌더링 될 모든 요소들이 렌더링 된 후 useEffect가 실행

 

List의 렌더링 과정 중에 setSelection하는 경우

  • List를 렌더링하는 과정에서 발생한 변경사항을 반영 후 나머지 요소 리렌더링

일반적으로 개선안 1은 잘 사용하는 패턴이 아니다!

 

개선안 2

const List = ({items}) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  
  const selection = items.find(item => item.id === selectedId) ?? null;
};
  • items가 변경돼 리렌더링할 때 items에 기존에 선택된 id가 없으면 null로 변경
  • 컴포넌트 리렌더링 중에 계산할 수 있으므로 가장 권장되는 방법

 

외부 저장소 구독하기

일반적인 방법

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }
 
    updateState();
 
    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}
 
function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

 

개선된 방법 - useSyncExternalStore 사용

hook에대한 이해가 없어 예제 코드를 이해하기 어려웠다. hook도 모르고, 예시로 사용한 API도 모르니 더 이해가 어렵다고 생각해 내가 아는 (window에서 사이즈 받아오기) 외부 정보를 구독하는 예시로 예제를 만들고 설명을 덧붙여달라고 챗지피티를 이용했다. 해당내용

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}
 
function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
    () => navigator.onLine, // 클라이언트에서 값을 얻는 방법
    () => true // 서버에서 값을 얻는 방법
  );
}
 
function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
  • 매개변수
    • subscribe: 저장소를 구독하는 동작
    • 저장소에서 값을 받아오는 동작
    • 서버에서 초기값을 제공하는 동작 (optional)
  • 구독한 외부 저장소에 변화가 생겼을 때를 감지해 리액트에서 그 값을 반영해 리렌더링
  • 사용하는 외부 저장소의 데이터가 바뀌는 것을 반영하기 위해 사용할 수 있음!

 

useEffect 수정하기

sortOption이 변경될 때마다 데이터 정렬하기

 

const [medalData, setMedalData] = useState([]);
const [sortOption, setSortOption] = useState("금은동 우선순위");

useEffect(() => {
  let sortedData = sortData(medalData);
  setMedalData(sortedData);
}, [sortOption]);

 

기존 방식의 문제점

  • 렌더링을 위해 데이터를 변환하는 경우에 해당
  • sortOption이 바뀌면서 1번, medalData가 변경되면서 1번 불필요한 리렌더링이 발생

 

변경

function RankingTable({ medalData, sortOption, deleteHandler }) {
  const sortedData = sortData(medalData, sortOption);
  return (
    <div id="rankingTable">
      {/* 생략 */}
        <tbody>
          {sortedData.map((data) => {
            return (
              <MedalTableRow
                key={data.country}
                data={data}
                deleteHandler={deleteHandler}
              ></MedalTableRow>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}
  • 데이터를 보여줄 RankingTable 컴포넌트에서 리렌더링 과정 중 정렬을 처리하는 것으로 변경

 

useMemo를 사용하지 않은 이유

데이터가 4개일 때 / 데이터가 34개일 때

  • 아티클에서는 반복되는 연산을 방지하기위해 useMemo를 사용할수도 있다, 1ms 이상의 계산은 이를 활용하라고 소개!
  • 궁금해서 소요 시간을 찍어보았으나, 데이터가 추가되어도 큰 차이가 없어 사용하지 않음

 

첫 로드일 때 localStorage에서 데이터 불러오기

const [initialLoad, setInitialLoad] = useState(true);
const [medalData, setMedalData] = useState([]);

useEffect(() => {
  if (initialLoad && localStorage.getItem("medalData")) {
    let savedData = JSON.parse(localStorage.getItem("medalData"));
    setMedalData(savedData);
    setInitialLoad(false);
  }
}, []);

 

기존 방식의 문제점

  • 그냥 state의 초기값을 지정해주면 되는데, 굳이 전체 렌더링 → effect로 값 받아와 다시 렌더링
  • 지금 구조에서는 한번 사용되고 말 initialLoad를 state로 관리

변경

const [medalData, setMedalData] = useState(() => {
  const savedData = localStorage.getItem("medalData");
  return savedData ? JSON.parse(savedData) : [];
});
  • 초기값을 지정할 때 localStorage에서 값을 받아와 존재한다면 JSON.parse, 없다면 빈 배열을 반환하도록 변경

 

useSyncExternalStore를 사용하지 않은 이유

  • state외의 공간에서 데이터를 관리하는 것은 맞지만, 프로젝트 내 동작을 제외하고 저장소의 데이터를 건드리지 않음
  • 따라서 외부 저장소의 변화를 계속 반영해줘야하는 상황이 아님

 

 

medalData가 변경될 때마다 localStorage에 저장

const [medalData, setMedalData] = useState([]);

useEffect(() => {
  if (initialLoad) return;
  localStorage.setItem("medalData", JSON.stringify(medalData));
}, [medalData]);

 

useEffect를 유지한 이유

  • 불필요한 렌더링을 발생시키지 않음
  • 어디서 medalData가 변경되더라도 로컬스토리지에 반영할 수 있음

 

변경

useEffect(() => {
  const storageData = localStorage.getItem("medalData");
  
  if (JSON.stringify(medalData) === storageData) return;
  localStorage.setItem("medalData", JSON.stringify(medalData));
}, [medalData]);
  • initialLoad state를 없애며 medalData 초기값을 설정한 값이 다시 저장되는 걸 방지하는 조건문이 사라짐
  • 기존 저장된 데이터와 현재 medalData가 일치하는 경우 진행하지 않도록 수정함

 

useEffect를 사용하기 전에 생각해보자!

  1. 단순히 state 변경 후 실행할 동작을 위해 사용하진 않는가?
  2. props, state로 계산할 수 있다면 렌더링 중에 계산할 수 있는 방법은 없을까?
  3. 이 동작이 사용자와의 상호작용을 반영하기 위한 것인가?