[React] Effect가 필요하지 않을 수도 있습니다

아래 두 자료를 공부하고 정리한 내용입니다!

You Might Not Need an Effect

 

Effect가 필요하지 않을 수도 있습니다 – React

The library for web and native user interfaces

ko.react.dev

 

Goodbye, useEffect - David Khourshid

 

 

useEffect 사용의 문제점

1) 코드의 맥락을 따라가기 어려움

2) 실행 속도가 불필요하게 느려질 수 있음

3) 에러 발생 가능성 증가 (복잡한 의존성 배열 등)

4) Strict 모드에서 이중으로 실행되는 문제

 

 

useEffect의 대안

1) 이벤트 핸들러 사용

- 폼 제출과 같은 간단한 이벤트는 이벤트 핸들러 내에서 직접 처리

 

2) 외부 라이브러리 사용

- 데이터 패칭 및 상태 관리를 위해 React Query, Remix, Next.js 등을 활용

 

 

Effect가 필요없는 일반적인 상황

렌더링을 위해 데이터를 변환하는 경우

- 리스트를 표시하기 전에 필터링을 해야하는 경우, 리스트가 변경될 때 state변수를 업데이트하는 Effect를 작성하고 싶을 수 있음

- 하지만 이는 비효율적!!

- state를 업데이트하면 React는 먼저 컴포넌트 함수를 호출해 화면에 표시될 내용을 계산하고, 변경사항을 DOM에 반영하여 화면을 업데이트함

- 그 후 React가 Effect를 실행

- 즉, 만약 Effect도 state를 즉시 업데이트한다면 변경된 state를 반영하여 렌더링하는 과정을 두번씩 하게되는 것

- 불필요한 렌더링 패스를 피하려면, 컴포넌트의 최상위 레벨에서 모든 데이터를 변환하자

-> 내 sortOption 변경시 sortData해주는 useEffect 이 경우 맞는지 다시 한 번 살펴보기

 

사용자 이벤트를 처리하는 경우

- 사용자가 제품을 구매할 때 /api/buy POST 요청을 전송하고 알림을 표시하는 기능을 만들고 싶음

- 구매 버튼 클릭 이벤트 핸들러에서는 정확히 어떤 일이 일어났는지 알 수 있음

- Effect가 실행될 때까지 사용자가 무엇을 했는지(ex. 어떤 버튼을 클릭했는지) 알 수 없음

- 따라서 Effect가 아닌 해당하는 이벤트 핸들러에서 사용자 이벤트를 처리해야 함

<button onClick={clickHandler}></button>

const [isClick, setIsClick] = useState(false);

// 이렇게 하지 마라
const clickHandler = () => {
  setIsClick(true);
};

useEffect(() => {
  if(isClick) {
   // API POST 요청 전송
   setIsClick(false); 
  }
}, [isClick]);


// 이렇게 해라
const clickHandler = () => {
  // API POST 요청 전송
};

 

 

props 또는 state에 따라 state 업데이트하기

예시 기능

✔︎ firstName, lastName 두개의 state 변수가 있다고 가정할 때 두 변수를 연결해서 fullName을 계산

✔︎ firstName, lastName이 업데이트 될 때마다 fullName이 업데이트됨

 

😈 피해야할 방법

- fullName state를 추가하고 Effect에서 의존성배열에 firstName, lastName을 담아 업데이트하기

const Form() => {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  
  const [fullName, setFullName] = useState('');
  
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
};

- setFirstName or setLastName 으로 인한 리렌더링 총 1회

- 이후 useEffect가 실행되며 setFullName으로 리렌더링 총 2회

🥳 개선된 방법

const Form() => {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  
  const fullName = firstName + ' ' + lastName;
};

- setFirstName 혹은 setLastName이 실행되면서 Form이 리렌더링 될 것이고 그 과정에서 fullName이 계산되어 업데이트 됨!!

- 리렌더링 총 1회

 

💡 고려할 부분

✔︎ 기존 props나 state에서 계산할 수 있다면 state에 넣지 않고 렌더링 중에 계산해야 함

- 연속적인 업데이트를 피할 수 있다

- 더 간단해진다

- 에러가 덜 발생한다

 

 

비용이 많이 드는 계산 캐싱하기

예시 기능

✔︎ props로 받은 todo를 filter에 따라 필터링하여 visibleTodos를 계산

 

😈 피해야할 방법

- visibleTodos라는 state를 만들고, useEffect로 todo or filter가 변경될 때를 감지하여 visibleTodos를 업데이트하기

const TodoList({todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);
};

- 위의 예시와 마찬가지로 visibleTodos는 todos, filter로 계산 가능하므로 state로 만들 필요가 없음!

 

🥳 개선된 방법 1

const TodoList({todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  const visibleTodos = getFilteredTodos(todos, filter);
};

- getFilteredTodos()와 todos가 처리가 느려질만큼 복잡하고, 많은 양이 아니라면 좋은 방법!

- 하지만 이 방법은 newTodo가 새로운 visibleTodos를 받는 것과는 상관없더라도 다시 계산하게 됨

 

🥳 개선된 방법 2

const TodoList({todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  const visibleTodos = useMemo(() => {
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
};

- useMemo를 이용해 값비싼 계산을 메모이제이션 하는 방식

- 의존성배열에 포함된 todos, filter가 변경되지 않는다면 getFilteredTodos를 실행하지 않는다!

- 따라서 렌더링 시에 계산됨 + todos, filter가 바꼈을 때에만 계산됨 두 조건을 모두 만족시킬 수 있음

 

💡 고려할 부분 - 값비싼 계산이란?

- 수천개의 객체를 만들거나, 수천번 반복하는 경우가 아니라면 비용이 많이 들지 않는다!

- 확인하고 싶은 경우 console.time -> console.timeEnd로 실행 시간을 측정할 수 있음

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

- 기록된 시간이 1ms 이상으로 합산되는 경우 메모이제이션을 활용하는 것이 좋음

- 사용자의 컴퓨터가 더 성능이 좋지 않을 것을 고려해 테스트하고 싶다면 CPU 스로틀링을 사용할 수 있음

 

 

Effect 없이 컴포넌트 state를 초기화하고 조정하는 방법

props 변경 시 모든 state 초기화

예시 기능

✔︎ 특정 유저의 프로필 페이지를 보여주는 컴포넌트

✔︎ userId를 props로 받고, state를 이용해 comment를 입력받음

✔︎ 다른 유저의 페이지로 이동시 comment를 빈 변수로 초기화시키려 함

 

😈 피해야할 방법

export default const ProfilePage({userId}) {
  const [comment, setComment] = useState('');
  
  useEffect(() => {
    setComment('')
  }, [userId]);
};

- 동일한 컴포넌트가 같은 위치에 렌더링되면서 state를 보존함

- ProfilePage와 그 자식 컴포넌트들이 모두 기존 값으로 렌더링 되었다가 Comment를 초기화하여 다시 렌더링함

- 댓글 UI가 중첩된 경우 중첩된 댓글 state도 비워야 함

 

🥳 개선된 방법 1 

- 명시적인 key를 전달하여 각 사용자의 프로필이 개념적으로 다른 프로필임을 React에 알리기

export default const ProfilePage = ({userId}) => {
  return (
    <Profile
      userId = {userId}
      key = {userId}
    />
  );
};

const Profile = (userId) => {
  const [comment, setComment] = useState('');
};

- userId를 key로 전달해 두 Profile 컴포넌트가 state를 공유해선 안되는 두개의 다른 컴포넌트로 취급하도록 요청

- key가 변경될 때마다 React는 DOM 을 다시 생성하고, 모든 state를 재설정(초기화)하게 됨

 

props 변경 시 일부 state 조정

예시 기능

✔︎ items를 props로 받아 selection state에 선택된 item을 보관

✔︎ items prop이 변경될 때마다 selection을 null로 재설정

 

😈 피해야할 방법

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

- 마찬가지로 selection을 null로 만들기 위해 두번의 리렌더링을 거쳐야 함

 

🥳 개선된 방법 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);
  }
};

- List가 렌더링될 때 state를 조정할 수 있음

 

setSelection이 두번 일어나는 건 마찬가지인데 왜 useEffect썼을 때랑 차이가 생기는 걸까???

렌더링 확인을 위해 간단히 테스트해봄 (strict mode 제거)

import { useEffect, useState } from "react";

function App() {
  const [items, setItems] = useState(0);

  return (
    <>
      <button
        type="button"
        onClick={() => {
          setItems(items + 1);
        }}
      >
        setItme!
      </button>
      <List items={items} />
    </>
  );
}

function List({ items }) {
  console.log("List 렌더링");
  const [selection, setSelection] = useState(0);

  // 개선안 사용하는 경우
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    console.log("prevItem과 비교");
    setPrevItems(items);
    setSelection(selection + 1);
  }

  // useEffect 사용하는 경우
  useEffect(() => {
  console.log("useEffect 실행");
  setSelection(selection + 1);
  }, [items]);

  return (
    <>
      <div>selection {selection}</div>
      <ChildComponent selection={selection} />
    </>
  );
}

function ChildComponent({ selection }) {
  console.log("ChildComponent 렌더링");

  return <div>ChildComponent {selection}</div>;
}

export default App;

변경사항이 있는 모든 하위 컴포넌트까지 렌더링 후 useEffect 실행 -> 또다시 필요한 렌더링 반복

 

return 문이 종료된 후 바로 다시 List 리렌더링 -> 그 후 ChildComponent까지 렌더링

 

💡 고려할 부분

- 이 방법은 useEffect보다는 효율적이지만 대부분의 경우에서는 필요하지 않다!

- key를 이용해 모든 state를 초기화하거나 렌더링 중에 state를 계산할 수 있는지 먼저 확인하는 것이 필요함

 

🥳 개선된 방법 2

const List = ({items}) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  
  const selection = items.find(item => item.id === selectedId) ?? null;
};

- 선택을 할 때 선택된 아이템의 id를 state로 저장해두고, item에서 해당하는 아이템이 있으면 유지, 없는 경우 null로 바꾸는 방법

- 복잡하지 않고 렌더링 중에 모든 계산이 완료되므로 가장 권장됨

 

이벤트 핸들러 간에 로직을 공유하는 방법

사용자의 입력에 의한 경우

예시 기능

✔︎ 제품을 구매할 수 있는 두개의 버튼(구매, 결제)가 있는 제품 페이지

✔︎ 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시

 

😈 피해야할 방법

function ProductPage({ product, addToCart }) {
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

- product page에서 product가 변할 때마다 showNotification을 호출하는 방식

- 만약 이 페이지를 새로고침하는 경우 장바구니 속 목록이 유지되는 한 계속해서 알림이 표시

 

🥳 개선된 방법

function ProductPage({ product, addToCart }) {
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

 

- 두 이벤트 핸들러에 showNotification과 공통 로직을 공유할 수 있도록 함수를 만들어주는 것이 맞는 방법

 

💡 고려할 부분

- 실행하고자 하는 로직 (현재는 알림을 띄우는 것)이 사용자의 입력 등에 의한 것인지, 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드인지 고려해야 함!

 

 

컴포넌트가 사용자에게 표시되었기 때문에 실행될 동작

예시 기능

✔︎ 마운트 될 때 analytics 이벤트 전송 (POST)

✔︎ 폼을 작성하고 Submit 버튼을 누르는 경우 /api/register 엔드포인트로 POST 요청 전송

 

😈 피해야할 방법

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // 컴포넌트가 마운트 되었을 때 실행돼야 함
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  
  // 사용자가 submit 버튼을 눌렀을 때 실행할 동작
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

- 컴포넌트가 마운트 되었을 때 실행해야할 동작에서는 useEffect를 활용한 것이 좋은 선택!

- 하지만 사용자 입력에 의해 실행할 동작에서는 useEffect를 활용하지 않는 것이 좋음

- 따라서 아래 동작은 이벤트 핸들러에서 처리되어야 함!!

 

🥳 개선된 방법

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    post('/api/register', { firstName, lastName });
  }
  // ...
}

 

 

💡 고려할 부분

✔︎ 이벤트 핸들러에 들어갈 로직: 상호작용에 의해 발생하는 경우

✔︎ Effect에 들어갈 로직: 사용자가 화면에서 컴포넌트를 보는 것이 원인인 경우

 

 

부모 컴포넌트에 변경 사항을 알리는 방법

자식 컴포넌트의 state 변경을 부모 컴포넌트에 알리기

예시 기능

✔︎ true or false로 변환될 state isOn이 있는 Toggle 컴포넌트

✔︎ isOn이 변경될 때마다 부모 컴포넌트의 onChange를 호출

 

😈 피해야할 방법

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

- setIsOn이 실행되면서 Toggle 컴포넌트 리렌더링

- useEffect 가 호출되면서 onChagne 호출하면서 부모 컴포넌트 리렌더링

 

🥳 개선된 방법 1

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

- 두 컴포넌트의 업데이트를 한번에 처리함

- 변화한 부분이 일괄처리되어 한번만 리렌더링

 

🥳 개선된 방법 2

function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

- 부모 컴포넌트에서 onChange, isOn을 관리하도록 변경한 방법

 

 

부모에게 데이터 전달하기

예시 기능

✔︎ 자식 컴포넌트에서 API fetch 기능을 수행 -> 부모 컴포넌트의 state에 저장해야 하는 경우

 

😈 피해야할 방법

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

- 자식에게 내려준 setData를 이용해 fetch한 데이터를 부모 컴포넌트의 state에 전달

- 자식 컴포넌트가 Effect를 활용해 부모 컴포넌트로 state를 업데이트하는 경우 데이터 흐름을 추적하기 어려움

- 자식과 부모 모두 동일한 데이터가 필요한 경우이므로, 부모에서 데이터를 fetch 후 자식에게 내려주는 방식이 더 권장됨!!

 

🥳 개선된 방법

function Parent() {
  const data = useSomeAPI();
  // ...
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

- 부모 컴포넌트에서 fetch 후 자식에게 props로 전달

 

 

외부 저장소 구독하기

예시 기능

✔︎ React state가 아닌 외부 저장소의 데이터를 구독하는 경우

✔︎ 외부에서 데이터가 변경되더라도 받아올 수 있도록 해야 함

 

😈 피해야할 방법 - Effect 활용

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();
  // ...
}

- 첫 마운트 시 navigator.onLine의 온/오프라인 상태에따라 updateState로 isOnline을 을 셋팅할 수 있는 이벤트 등록

- 언마운트 될 때 이벤트도 해제됨

- 일반적으로 위와 같이 Effect를 사용하지만, React에는 외부 저장소를 구독하기 위한 Hook이 있음!

 

🥳 개선된 방법 - useSyncExternalStore

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();
  // ...
}

- useSyncExternalStore 이용해 온/오프라인 상태에 따라 navigator.onLine에서 데이터를 받아오거나 상태를 알리는 callback을 이벤트를 등록함

- navigator.onLine의 상태가 변경되면 callback이 호출되며 컴포넌트는 리렌더링 됨

 

위 예제의 callback은?

subscribe의 매개변수로 전달된, 이벤트로 등록된 callback의 존재는 무엇일까?

이는 React가 내부적으로 관리하는 함수로 외부 데이터 소스의 변경을 React에게 알리는 역할을 한다.

- useSyncExternalSotre 훅을 실행할 때 콜백함수를 자동으로 생성

- 이 콜백이 호출될 때 React는 컴포넌트의 리렌더링을 트리거

 

- 메달 트래커 예제에는 적합하지 않은 것 같음

이유: 외부에서 계속 값이 변경되고, 그걸 받아와서 사용하는 것이 아니라 마운트시 한번 불러오고 사용자 조작으로 값이 변경되기 때문

 

 

데이터 fetch

- fetch를 위해 Effect를 사용하는 것은 일반적이지만 주의할 사항들이 있음

 

😈 피해야할 방법

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

 - 데이터 fetch는 주된 이유가 입력 이벤트가 아니기에 이를 이벤트 핸들러로 옮길 필요는 없음

- 하지만 위 예제처럼 클릭 이벤트가 아닌 input의 onChange에 의한 경우라면 "h", "he", "hel", ... , "hello"에 대한 fetch가 각각 진행되지만 최종적으로 어떤 데이터가 도착할지는 알 수 없다!!
- 이를 경쟁조건 이라고 하는데, useEffect에서 fetch를 위해선 위 예시 코드에서 끝나는 것이 아니라 경쟁을 정리하는 함수를 추가해야 함

 

🥳 개선된 방법 - 정리 함수 추가

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

- useEffect 내부의 return () => {} 이 정리함수!

- useEffect의 콜백함수가 호출되는 다음 시점에 이전 Effect의 정리함수가 실행됨

- 빠르게 입력을 했을 경우 마지막 입력에 대한 응답보다 이전 응답이 늦게 fetch 되더라도 이미 정리함수로 ignore 는 true로 바꼈으므로 setResult로 진입하기 위한 조건을 만족시키지 못해 응답이 무시된다!!!! 

 

 

정리 함수

리액트의 정리함수 호출 시점

1) 컴포넌트가 언마운트 될 때

2) 의존성 배열의 값이 변경되어 Effect가 다시 실행되기 직전

 

실행 순서

1) 이전 효과의 정리 함수 실행

2) 새로운 효과 실행

 

'React' 카테고리의 다른 글

[React + Supabase] 설치, 셋팅, 간단한 사용법  (0) 2024.08.20
[React] react-router-dom  (0) 2024.08.20
[React] useSyncExternalStore  (0) 2024.08.18
[React] memoization  (0) 2024.08.17
[React] useContext  (0) 2024.08.16