setState 파헤치기

2022년 11월 16일

Updated on 2024년 12월 12일

#React#Setstate#Hook

서론

  • React를 사용하게 되면 state를 업데이트 해야하는 상황이 온다.
  • state를 업데이트할 때 사용하는 useStatesetState에 대해 알아보자.
  • setState를 사용할때 함수를 넣는 경우와 값을 직접 넣는 경우를 본 적이 있을 것이다.
  • 각각의 경우가 어떠한 방식으로 실행되고 차이점은 무엇일까?
  • setState가 무엇인지 그리고 어떠한 방식으로 실행되는지 알아보자

setState

  • React를 쓰면서 가장 많이 쓰는 메소드이다.
  • useState 훅에서 호출하는 메소드 이다.
  • state의 업데이트를 위해 사용된다.
  • state를 업데이트하면 리랜더링이 일어난다.
  • 비동기적으로 동작한다.

사용법

1. updater 함수를 이용한 방식 (권장)

  • 형태
  • 이전 상태값을 안전하게 참조할 수 있어 연속적인 업데이트에 적합하다.
const [state, setState] = useState(0);

setState((prev) => prev + 1);

2. state 값을 이용한 방식

  • 형태
  • 단순한 상태 업데이트에 사용할 수 있지만, 연속적인 업데이트시 주의가 필요하다.
const [state, setState] = useState(0);

setState(state + 1);

차이점

  • 가장 큰 차이점은 state변경 시 이전 값의 보장 유무이다.
  • 이게 무슨 말이냐? 예제를 보자
  • 위와 똑같이 state를 1씩 증가시키는 함수가 있다고 가정하자
  • setState는 똑같이 1씩 증가시킨다. 그렇다면 setState를 두번 실행시키는 increase1, increase2에서 값이 동일하게 2씩 늘어날까?
const [state, setState] = useState(0);

const increase1 = () => {
  setState((prev) => prev + 1);
  setState((prev) => prev + 1);
};
// result +2 per click

const increase2 = () => {
  setState(state + 1);
  setState(state + 1);
};
// result +1 per click

결과

  • increase1 = +2 증가하고, increase2 = +1 증가한다.

Why?

  • 바로 setState비동기적으로 동작하기 때문이다.
  • setState는 호출 즉시 동작하지 않는다.
  • React에서는 효율적으로 처리하기 위해서 state를 변경하겠다고 대기열에 추가한 뒤, 여러 변경 사항들과 묶어서 한번에 업데이트 한다.
  • 그렇기 때문에 increase2의 경우 setState를 2번 호출하지만 한꺼번에 묶어서 처리하기 때문에 +1씩 증가하는 것이다.
  • 함수를 이용한 방식은 setStatestate를 변경하는 시점에 이전 값을 참조하기 때문에 +2씩 증가한다.
  • 이러한 이유로 연속적인 상태 업데이트가 필요한 경우 updater 함수를 사용하는 것이 안전하다.

React 18의 Automatic Batching

  • React 18부터는 자동 배칭(Automatic Batching)이 도입되었다.
  • 단, createRoot를 사용할 때만 적용된다.
  • 이전에는 React 이벤트 핸들러 내부에서만 배칭이 발생했다.
  • 이제는 Promise, setTimeout, 이벤트 핸들러 등 모든 곳에서 배칭이 발생한다.
  • 배칭은 여러 상태 업데이트를 하나의 리렌더링으로 묶어서 처리함으로써 성능을 최적화한다.
  • 배칭을 피해야 하는 경우 flushSync를 사용할 수 있다.
  • 단, flushSync는 성능에 부정적인 영향을 미칠 수 있으므로 꼭 필요한 경우에만 사용해야 한다.
  • 예제를 보자
// React 18 이전
setTimeout(() => {
  setCount((c) => c + 1); // 리렌더링 발생
  setFlag((f) => !f); // 리렌더링 발생
}, 1000);

// React 18
setTimeout(() => {
  setCount((c) => c + 1); // 배칭
  setFlag((f) => !f); // 배칭
}, 1000); // 한번의 리렌더링

// 배칭 피하기 (꼭 필요한 경우에만 사용)
import { flushSync } from "react-dom";

const handleClick = () => {
  flushSync(() => {
    setCount((c) => c + 1); // 즉시 리렌더링
  });
  flushSync(() => {
    setFlag((f) => !f); // 즉시 리렌더링
  });
};

객체나 배열 상태 다루기

  • state가 객체나 배열일 때는 더욱 주의해야 한다.
  • React에서 불변성(Immutability)을 지키는 것은 매우 중요하다.
  • 불변성을 지켜야 React가 상태 변화를 감지하고 효율적으로 리렌더링할 수 있다.
  • 얕은 복사로 인해 의도치 않은 결과가 발생할 수 있다.
  • 중첩된 객체나 배열을 다룰 때는 더욱 주의가 필요하다.
  • 복잡한 객체의 경우 Immer 같은 도구를 사용하면 불변성을 쉽게 유지할 수 있다.
  • 예제를 보자
const [user, setUser] = useState({
  name: "John",
  age: 20,
  address: {
    city: "Seoul",
    street: "Gangnam",
  },
  hobbies: ["reading", "gaming"],
});

// ❌ 잘못된 방법
const updateAddress = () => {
  user.address.city = "Busan"; // 직접 수정
  setUser(user); // 참조가 같아서 리렌더링 안됨
};

const addHobby = () => {
  user.hobbies.push("coding"); // 직접 수정
  setUser(user); // 참조가 같아서 리렌더링 안됨
};

// ✅ 올바른 방법
const updateAddress = () => {
  setUser((prev) => ({
    ...prev,
    address: {
      ...prev.address,
      city: "Busan",
    },
  }));
};

const addHobby = () => {
  setUser((prev) => ({
    ...prev,
    hobbies: [...prev.hobbies, "coding"],
  }));
};

// ✅ Immer를 사용한 방법
import produce from "immer";

const updateAddressWithImmer = () => {
  setUser(
    produce((draft) => {
      draft.address.city = "Busan";
    })
  );
};

여러 상태 동시에 업데이트하기

  • 여러 상태를 한번에 업데이트 해야할 때가 있다.
  • 이럴 때는 useReducer를 고려해보는 것이 좋다.
  • useReducer는 다음과 같은 경우에 특히 유용하다:
    • 여러 상태가 서로 연관되어 있을 때
    • 다음 상태가 이전 상태에 의존적일 때
    • 상태 업데이트 로직이 복잡할 때
  • 예제를 보자
// 여러 상태를 개별적으로 업데이트
const updateUserInfo = () => {
  setName("John");
  setAge(25);
  setEmail("john@example.com");
};

// useReducer를 사용한 방법
const reducer = (state, action) => {
  switch (action.type) {
    case "UPDATE_USER":
      return {
        ...state,
        name: action.payload.name,
        age: action.payload.age,
        email: action.payload.email,
      };
    default:
      return state;
  }
};

const updateUserInfo = () => {
  dispatch({
    type: "UPDATE_USER",
    payload: {
      name: "John",
      age: 25,
      email: "john@example.com",
    },
  });
};

성능 최적화

  • state 업데이트는 리렌더링을 발생시키므로 성능에 영향을 준다.
  • 성능 최적화는 실제 성능 측정 후에 진행하는 것이 좋다.
  • React DevTools의 Profiler를 사용해 성능 병목지점을 찾아보자.
  • useCallbackuseMemo는 신중하게 사용해야 한다:
    • 이러한 최적화가 항상 성능 향상을 보장하지는 않는다.
    • 때로는 불필요한 복잡성만 추가할 수 있다.
    • 실제 성능 측정 후 필요한 경우에만 적용하자.
// 🤔 최적화가 필요한지 고민이 필요한 경우
const Component = ({ items }) => {
  const [filter, setFilter] = useState("");

  // 매 렌더링마다 새로운 함수 생성
  const handleFilter = (value) => {
    setFilter(value);
  };

  // 매 렌더링마다 필터링 로직이 재실행됨
  const filteredItems = items.filter((item) => item.name.includes(filter));

  return (
    <div>
      <input onChange={(e) => handleFilter(e.target.value)} />
      <ItemList items={filteredItems} />
    </div>
  );
};

// ✅ 성능 측정 후 최적화가 필요한 경우
const Component = ({ items }) => {
  const [filter, setFilter] = useState("");

  // 성능 측정 결과 최적화가 필요한 경우에만 적용
  const handleFilter = useCallback((value) => {
    setFilter(value);
  }, []); // 의존성 없음

  // 필터링 비용이 큰 경우에만 메모이제이션 적용
  const filteredItems = useMemo(
    () => items.filter((item) => item.name.includes(filter)),
    [items, filter] // items나 filter가 변경될 때만 재계산
  );

  return (
    <div>
      <input onChange={(e) => handleFilter(e.target.value)} />
      <ItemList items={filteredItems} />
    </div>
  );
};

마무리

  • setState는 React에서 상태를 관리하는 핵심 메커니즘이다.
  • 상태 업데이트시 updater 함수 사용을 권장한다.
  • 불변성을 지키는 것이 매우 중요하다.
  • 성능 최적화는 실제 측정 후 적용하자.
  • React 18의 Automatic Batching을 활용하면 더 나은 성능을 얻을 수 있다.

참조

피드백은 언제나 환영입니다.