2차 공부/TIL

24.08.15 챌린지 아티클 -2 / You might not need an effect

공대탈출 2024. 8. 15. 13:54

useEffect는 외부시스템과 컴포넌트를 동기화 할 수 있는 기능이다. 하지만 외부시스템이 관여하지 않는 경우, 우리는 effect가 필요하지 않다.

따라서 불필요한  Effect를 제거하여 코드를 직관적이게 바꾸고, 실행 속도가 빨라지며, 의도하지 않은 에러를 방지할 수 있다.

 

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

import { useEffect } from "react";
import { useState } from "react";

const App = () => {
    const [firstName, setFirstName] = useState("");
    const [lastName, setLastName] = useState("");
    const [fullName, setFullName] = useState("");

    useEffect(() => {
        setFullName(firstName + "" + lastName);
        console.log("fullName변경 > ", fullName);
    }, [firstName, lastName]);

    return (
        <>
            <p>{fullName}</p>
            <input
                onChange={(e) => {
                    setFirstName(e.target.value);
                }}
            />
            <input
                onChange={(e) => {
                    setLastName(e.target.value);
                }}
            />
        </>
    );
};

export default App;

위 코드는 firstName, lastName이라는 state가 있고, 둘을 합친 fullName이라는 State가 있는 App컴포넌트이다.

이때 firstName이나 lastName을 input에 입력하여 상태변화가 일어나면 useEffect의 의존성배열에 두 상태가 들어가 있으므로 내부 함수를 실행하게 된다.

내부 함수에서는 다시 fullName을 변화한 firstName과 lastName을 합친 값으로 setState를 해주는데, 이는 불필요한 리렌더링을 유발한다.

 

fullName의 이전 값으로 전체 렌더링을 수행한 뒤, 업데이트된 fullName으로 다시 렌더링 하기 때문에 비효율적이다.

import { useEffect } from "react";
import { useState } from "react";

const App = () => {
    const [firstName, setFirstName] = useState("");
    const [lastName, setLastName] = useState("");
    const fullName = firstName + "" + lastName;

    return (
        <>
            <p>{fullName}</p>
            <input
                onChange={(e) => {
                    setFirstName(e.target.value);
                }}
            />
            <input
                onChange={(e) => {
                    setLastName(e.target.value);
                }}
            />
        </>
    );
};

export default App;

따라서 단순히 fullName을 계산된 상수로 만들어준다.

이렇게 되면 firstName이나 lastName이 변경되어 App컴포넌트가 리렌더링 될 때 다시 계산이 실행되므로 불필요한 리렌더링을 방지할 수 있다.

 

기존 props나 state에서 계산할 수 있는 것이 있다면, 그것을 굳이 State에 넣어 관리하지않고 렌더링 중 계산하도록 하자.

이렇게 하면 실행이 빨라지고, 코드가 조금 더 깔끔해지며, 에러를 방지할 수 있다.

 

리액트로 사고하기

이는 리액트로 사고하기라는 리액트 공식문서중 state에 적절한 데이터를 말해주는 부분이다.

위에서 했듯이 fullName은 다른 state들로 계산이 가능하기 때문에 state로 만드는 것은 적절하지 않다.

 

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

import React, { useEffect, useState } from "react";

const getFilteredNums = (array) => {
    return array.filter((num) => num % 2 === 0);
};

const App = () => {
    const [numArr, setNumArr] = useState([]);
    return (
        <div>
            <button
                onClick={() => {
                    let newNums = [...numArr, numArr.length];
                    setNumArr(newNums);
                }}
            >
                숫자 추가
            </button>
            <Num array={numArr} />
        </div>
    );
};

export default App;
const Num = ({ array }) => {
    const [filteredNumArr, setFilteredNumArr] = useState([]);
    useEffect(() => {
        setFilteredNumArr(getFilteredNums(array));
    }, [array]);

    return <>{filteredNumArr}</>;
};

Num 컴포넌트는 props로 받은 array를 어떤 함수로 필터링하여 filteredNumArr을 계산하여 state에 저장한다.

이때 array의 배열이 변경되면 effect에서 다시 해당 함수를 실행하여 setState를 진행한다.

 

아까와 마찬가지로 불필요하며 비효율적인 코드이다. 따라서 단순계산으로 바꿔주어야한다.

const Num = ({ array }) => {
    const filteredNumArr = getFilteredNums(array);
    return <>{filteredNumArr}</>;
};

 

하지만 이 코드도 비효율적이다. 

만약 App컴포넌트에 다른 State가 존재하고 그 State가 변경된다면?

그럼 Num도 리렌더링이 일어나며 다시 getFilteredNums의 로직에 따라 계산을하고, 화면에 보여줄 것이다.

array와 관련없는 변경인데도 말이다. 따라서 이럴때는 useMemo 훅을 사용하여 계산로직을 캐싱해야한다.

import React, { useEffect, useMemo, useState } from "react";

const getFilteredNums = (array) => {
    return array.filter((num) => num % 2 === 0);
};

const App = () => {
    const [numArr, setNumArr] = useState([]);
    const [count, setCount] = useState(0);
    return (
        <div style={{ marginLeft: "20px" }}>
            <button
                onClick={() => {
                    let newNums = [...numArr, numArr.length];
                    setNumArr(newNums);
                }}
            >
                숫자 추가
            </button>
            <Num array={numArr} />

            <p>Count: {count}</p>
            <button
                onClick={() => {
                    setCount(count + 1);
                }}
            >
                count + 1
            </button>
        </div>
    );
};

export default App;
const Num = ({ array }) => {
    const filteredNumArr = useMemo(() => {
        console.log("다시 실행되나?");
        return getFilteredNums(array);
    }, [array]);

    return <>{filteredNumArr}</>;
};

위와 같이 App컴포넌트에 count라는 state가 따로 존재하고, 이 값을 변경하는 버튼을 눌렀을때 Num컴포넌트에서는 useMemo의 의존성 배열에 array, 즉 numArr에 의존하고 있기 때문에 계산 로직이 다시 실행되지 않는다.

이렇게 진행하면 getFilteredNums라는 함수는 초기 렌더링시, numArr의 변경이 있을때만 다시 실행된다.

 

prop 변경 시 모든 State 초기화

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

  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

프로필 페이지에서 userId를 props로 받는데, 해당 유저에게 댓글을 남기기 위해 comment라는 state를 만들었다.

그리고 다른 유저의 프로필페이지로 이동할 때 comment를 비우기 위해 userId가 바뀔 때마다 setComment('')의 작업을 한다고 useEffect()를 사용하였다.

이는 ProfilePage가 오래된 값으로 렌더링 한 다음, useId의 변경에 의한 comment state의 변경으로 다시 렌더링이 발생하여 복잡하고 비효율적인 코드가 된다. 

이럴때는 key를 전달하여 각 사용자의 프로필이 다르다는 것을 react에게 알려주는 방법을 사용하면 된다.

 

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

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

React는 동일 컴포넌트가 같은 위치에 렌더링 될 때 state를 보존한다. 하지만 Profile컴포넌트에 각기 다른 userId를 key로 전달하면 React가 각각 다른 컴포넌트로 인식하기 때문에 comment state가 자동으로 재설정되는 것이다.

 

prop이 변경될 때 일부 state 조정하기

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

  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

List에서 items를 props로 받는데, 해당 값이 변경될 때마다 selection을 null로 초기화하는 작업을 useEffect로 설정하였다.

items의 변화가 있는 것이니 먼저 items를 가진 부모 컴포넌트가 리렌더링 된다.(자식컴포넌트 포함)

그리고 props로 해당 변화를 감지한 List컴포넌트의 useEffect가 작동하여 selection state를 변경한다.

그러면 List컴포넌트가 다시 리렌더링되고, 자식1, 자식2 컴포넌트까지 리렌더링이 되는 것이다.

따라서 이것은 비효율적인 useEffect사용이다.

 

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

  // 더 좋습니다: 렌더링 중 state 조정
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

해당 코드에서는 부모 컴포넌트 리렌더링에 의한 List컴포넌트 리렌더링 시 비교를 하여 작업을 진행한다.

위에서 말했듯, React는 같은 위치에 동일한 컴포넌트가 렌더링되면 state의 값을 변경하지 않는다고 말하였다.

따라서 prevItems에는 부모컴포넌트에서 변경하기 전의 items값이 들어있고, if의 조건에서의 items는 변경된 items가 담겨있다.

그러므로 변경이 되었을 때 prevItems를 새로운 값으로 변경해주고, selection을 null로 바꾸는 작업을 하는 것이다.

이렇게 작업을 하면 useEffect에 의해 일어났던 불필요 리렌더링이 줄어들게 된다.

이때 selection과 prevItems의 setState에 의해 List가 리렌더링이 되는 것이 아니냐 라고 생각할 수도 있지만, 이떄는 List 자식을 렌더링하거나 DOM을 업데이트하지 않았기 때문에 불필요 리렌더링이 일어나지 않는 것이다.

 

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 최고예요: 렌더링 중에 모든 것을 계산
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

이는 렌더링 중 계산으로 selection을 지정하는 것이므로 setState를 사용하는 이전 코드보다 더 나은 방법이다.

 

이벤트 핸들러 간 로직 공유

function ProductPage({ product, addToCart }) {
  // 🔴 피하세요: Effect 내부의 이벤트별 로직
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

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

물건을 장바구니에 넣을 때 product가 바뀌는데 두가지 함수에서 반복되어 사용되니 product를 의존성 배열에 넣고 useEffect로 showNotification함수를 실행한다.

이렇게 작성하면 페이지가 로드될 때마다 알림이 나오게 될 것이다. product.isInCart가 true이므로 해당 제품페이지를 새로고칠 때마다 showNotification이 실행되는 것이다.

따라서 반복되는 addToCart와 그에 따라오는 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');
  }
  // ...
}

이렇게 하면 새로고침 시 불필요한 알림을 뜨는 것을 막을 수 있다.

 

저번 개인과제를 진행할 때 alert를 띄워줘야하는 상황이 여럿 있었다. 그 상황을 state에 저장하고 useEffect를 사용하여 state가 바뀔때마다 어떤 작업을 하게 했다면 위 문제상황처럼 되었을 것이다.

const setModal = (text) => {
    setAlertText(text);
    modalRef.current.showModal();
};

const formCheck = (isupdating = false) => {
    const trimmedName = form.country.trim();
    if (!trimmedName) {
        setModal("국가명을 입력해주세요");
        return true;
    }
    if (form.gold < 0 || form.silver < 0 || form.bronze < 0) {
        setModal("메달수는 0이상만 입력가능합니다.");
        return true;
    }
    const index = countries.findIndex((el) => el.country === form.country);
    if (isupdating && index === -1) {
        setModal("등록되지 않은 국가입니다.");
        return true;
    }
    if (!isupdating && index !== -1) {
        setModal("이미 존재하는 국가입니다.");
        return true;
    }
};

위의 setModal이 아래와 같이 작동했다면 생각하지 못한 부분에서 오류가 났을 것이 분명하다.

useEffect(()=>{
	modal.current.showModal()
},[alertText])

 

POST 요청 보내기

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

  // ✅ 좋습니다: 컴포넌트가 표시되었으므로 이 로직이 실행되어야 합니다.
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 피하세요: Effect 내부의 이벤트별 로직
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

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

두가지 useEffect가 사용되었다. 한개는 컴포넌트가 마운트 되었을 때 GA같은 사용자분석관련 서버에 해당 기능에 진입했다고 post요청을 보내는 것이고, 한개는 제출버튼을 눌렀을 때 특정 state의 변화에 따라 post요청을 보내는 것이다.

 

이걸 보고 저렇게까지 useEffect를 좋아하는 사람이 있을까라는 생각도 들었다. 어쨌든 불필요한 useEffect사용이다.

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

  // ✅ 좋습니다: 컴포넌트가 표시되었으므로 이 로직이 실행됩니다.
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // firstName과 lastName값에 대한 유효성 검사로직
    // ...
    // ✅ 좋습니다: 이벤트별 로직은 이벤트 핸들러에 있습니다.
    post('/api/register', { firstName, lastName });
  }
  // ...
}

위 코드에서 원했던 null값이 아닐 때 혹은 특정 규칙에따른 유효성 검사 후 post요청을 submitHandler에서 진행하면 되는 것이다.

 

연쇄 계산

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 피하세요: 서로를 트리거하기 위해서만 state를 조정하는 Effect 체인
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

handlePlaceCard가 처음 작동되면 setCard(nextCard)가 작동된다.

그러면 state의 변화로 리렌더링이 일어나고 첫 useEffect의 의존성배열인 card가 변화하여 setGoldCardCount를 진행한다.

또 state의 변화가 일어나 다시 리렌더링이 일어나고 또 리렌더링 또 리렌더링 ... 비효율적인 useEffect 체이닝? 이다.

 

다시 상기해보자. 같은 위치의 같은 컴포넌트가 렌더링되면 state값이 유지된다.(key값이 다르지 않다면)

따라서 렌더링 중 계산가능한 부분을 useEffect에서 빼내는 것이 올바른 코드 작성이다.

 

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ 렌더링 중에 가능한 것을 계산합니다.
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ 이벤트 핸들러에서 다음 state를 모두 계산합니다.
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

이벤트 핸들러에서 setState를 작성하게되면state가 스냅샷처럼 동작하게된다. setRound(round + 1)을 호출한 뒤 round는 사용자가 버튼을 클릭한 시점의 값을 반영하기 때문이다.

 

애플리케이션 초기화

사용자로그인정보를 체크하거나, 로컬스토리지에서 데이터를 가져오는 등의 로직은 앱이 로드될 때 한 번만 실행되어야 한다. 

const useCountry = () => {
    useEffect(() => {
        if (localStorage.getItem("countries")) {
            const localCountries = JSON.parse(localStorage.getItem("countries"));
            setCountries(localCountries);
        }
    }, []);

 

let init = false;

const useCountry = () => {
    useEffect(() => {
        if (!init && localStorage.getItem("countries")) {
            init = true;
            const localCountries = JSON.parse(localStorage.getItem("countries"));
            setCountries(localCountries);
        }
    }, []);
    //...
}

커스텀훅이나 컴포넌트 외부에 bool값을 만들고, 처음 로드시 해당 값을 바꾸어 다시 로드되지 않도록 설정해주면 불필요 렌더링을 방지할 수 있다.

 

요약

  • 렌더링 중 계산할 수 있다면 Effect가 필요하지 않다.
  • 비용이 많이드는 계산이 필요하다면 useEffect보다 useMemo가 적절하다.
  • 전체 컴포넌트 트리의 state를 초기화하려면 각기 다른 key를 전달하자
  • prop 변경에 대한 응답으로 특정 state를 초기화하려면 렌더링 중에 설정하자
  • 컴포넌트가 표시되어 실행되는 코드는 Effect에 있어야하고, 나머지는 이벤트핸들러에 있어야 한다.
  • 여러 컴포넌트의 state를 업데이트해야 하는 경우 단일 이벤트 중에 수행하는 것이 좋다.
  • 다른 컴포넌트의 state변수를 동기화하려고 할 때마다 state 끌어올리기를 고려하자
  • Effect로 데이터를 가져올 수 있지만 경쟁조건을 피하기 위해 정리를 구현해야 한다.

'2차 공부 > TIL' 카테고리의 다른 글

24.08.16 redux 설정하기  (0) 2024.08.15
24.08.16 리액트 숙련주차 강의  (3) 2024.08.15
24.08.14 리액트 개념 아티클  (0) 2024.08.14
24.08.13 개인과제 리팩토링  (0) 2024.08.13
24.08.12 개인과제 리팩토링  (0) 2024.08.12