학습 자료

useState 제대로 쓰기


Article

8강에서 setCount(count + 1)을 세 번 불렀더니 1이 됐다. 이번 강의는 그 지점에서 시작한다.

갱신 함수 — 이전 값을 받아온다

setState에는 값 대신 함수를 넘길 수 있다.

setCount(count + 1);

setCount(c => c + 1);

아래 형태는 React가 가장 최신 값을 인자로 넣어준다. 그래서 연달아 불러도 쌓인다.

function handleClick() {
  setCount(c => c + 1);
  setCount(c => c + 1);
  setCount(c => c + 1);
}
콘솔
  • 3
React 가 함수를 줄 세워 처리한다
  1. 01

    함수 세 개를 큐에 넣는다

    당장 실행하지 않는다. "이따 순서대로 적용해라" 하고 쌓아둔다.

  2. 02

    첫 함수에 현재 값을 넣는다

    0 이 들어가 1 이 나온다.

    c => c + 1   // 0 → 1
  3. 03

    그 결과를 다음 함수에 넣는다

    1 이 들어가 2 가 나오고, 또 3 이 된다.

  4. 04

    최종값으로 리렌더링

    count3 이 된다.

언제 무엇을 쓰나

setCount(값)

새 값이 이전 값과 무관할 때

  • setCount(0) — 초기화
  • setName(input) — 입력값 대입
  • 읽기 쉽다

setCount(함수)

이전 값으로 새 값을 만들 때

  • setCount(c => c + 1)
  • setItems(prev => [...prev, item])
  • 항상 최신 값을 보장한다

배칭 — 한 번에 모아서 그린다

setState를 세 번 부르면 화면을 세 번 그릴까. 아니다.

function handleClick() {
  setCount(c => c + 1);
  setName("지원");
  setIsOpen(true);
}

React는 이벤트 핸들러가 끝날 때까지 기다렸다가 한 번만 다시 그린다. 이걸 **배칭(batching)**이라 부른다.

배칭이 없다면 vs 있다면
배칭 없음
set렌더set렌더set렌더
배칭 (실제)
setsetset렌더 한 번
핸들러 시작핸들러 끝화면 갱신
화면이 중간 상태로 깜빡이지 않고, 렌더링도 한 번만 일어난다

덕분에 화면이 중간 상태로 깜빡이지 않는다. 그리고 8강에서 count가 안 바뀌던 이유이기도 하다. 핸들러가 끝나기 전까지는 아직 다시 그려지지 않았다.

객체를 state 에 담을 때

여기서부터 실수가 많이 나온다.

const [user, setUser] = useState({ name: "지원", age: 27 });

function handleClick() {
  user.age = 28;
  setUser(user);
}

화면이 안 바뀐다. 왜일까.

자바스크립트 편 17강에서 객체 비교는 내용이 아니라 주소를 본다고 했다. user.age를 바꿔도 user가 가리키는 주소는 그대로다. React는 Object.is로 이전 값과 비교하는데, 같은 주소니까 "안 바뀌었네" 하고 그냥 넘어간다.

직접 고치면

주소가 같으니 React 는 변화를 알아채지 못한다

이전 user
0x1a2b
{ name: '지원', age: 28 }
새 user
0x1a2b
같은 객체 — 바뀐 게 없다고 판단

새 객체를 만들어야 한다. 자바스크립트 편 18강의 스프레드를 쓴다.

function handleClick() {
  setUser({ ...user, age: 28 });
}

{ ...user }가 새 객체를 만들고, age만 덮어쓴다. 주소가 다르니 React가 알아챈다.

배열을 state 에 담을 때

같은 규칙이다. 자바스크립트 편 15강에서 배운 원본을 바꾸는 메서드와 안 바꾸는 메서드의 구분이 그대로 쓰인다.

하려는 일쓰면 안 되는 것써야 하는 것
추가items.push(x)[...items, x]
앞에 추가items.unshift(x)[x, ...items]
삭제items.splice(i, 1)items.filter(…)
수정items[i] = xitems.map(…)
정렬items.sort()[...items].sort()
const [items, setItems] = useState([]);

function addItem(item) {
  setItems(prev => [...prev, item]);
}

function removeItem(id) {
  setItems(prev => prev.filter(item => item.id !== id));
}

function toggleItem(id) {
  setItems(prev =>
    prev.map(item =>
      item.id === id ? { ...item, done: !item.done } : item
    )
  );
}

map 안에서도 { ...item, done: ... }으로 새 객체를 만든다. 바꿔야 할 것만 새로 만들고 나머지는 그대로 둔다.

초기값은 첫 렌더링에만 쓰인다

function Counter({ initial }) {
  const [count, setCount] = useState(initial);
  ...
}

initial이 나중에 바뀌어도 count는 안 바뀐다. useState의 인자는 첫 렌더링에서만 읽힌다. 이후 렌더링에서는 무시된다.

초기값 계산이 무겁다면 함수를 넘긴다.

const [items, setItems] = useState(loadFromStorage());

const [items, setItems] = useState(() => loadFromStorage());

위쪽은 렌더링할 때마다 loadFromStorage()를 실행한다. 결과는 버려지는데 실행은 된다. 아래쪽은 첫 렌더링에서 딱 한 번만 부른다.

state 를 몇 개로 나눌까

하나의 객체로 묶을지, 여러 개로 나눌지 고민된다.

const [name, setName] = useState("");
const [email, setEmail] = useState("");

const [form, setForm] = useState({ name: "", email: "" });
나눌까 묶을까

따로따로

서로 무관한 값

  • 갱신이 간단하다
  • 무엇이 바뀌는지 명확
  • 대부분 이쪽이 낫다

객체로 묶기

항상 함께 바뀌는 값

  • 폼 필드가 많을 때
  • 스프레드를 매번 써야 한다
  • 필드를 빠뜨리면 값이 사라진다

기본은 나누는 쪽이다. 함께 바뀌는 게 확실할 때만 묶는다.

묶었다면 갱신할 때 스프레드를 잊지 않는다.

setForm({ email: value });

setForm(prev => ({ ...prev, email: value }));

위쪽은 name이 통째로 사라진다. 아래쪽 화살표 함수에서 객체를 괄호로 감싼 것도 눈여겨본다. 3강에서 본 그 규칙이다.

정리하면

  • 이전 값으로 새 값을 만든다면 갱신 함수를 넘긴다. setCount(c => c + 1)
  • React는 핸들러가 끝날 때까지 모아뒀다가 한 번만 다시 그린다. 이게 배칭이다.
  • state를 직접 고치면 React가 알아채지 못한다. 객체 비교는 주소로 하기 때문이다.
  • 항상 새 객체·새 배열을 만든다. push, splice, sort를 쓰지 않는다.
  • 스프레드는 얕은 복사다. state 구조를 얕게 유지한다.
  • useState의 인자는 첫 렌더링에서만 읽힌다. 무거운 계산은 함수로 넘긴다.
  • state는 기본적으로 나눈다. 함께 바뀌는 것만 묶는다.

다음 강의에서는 사용자의 클릭과 입력을 받는 법, 이벤트 처리를 본다.

useState 제대로 쓰기 — 디코드랩(DCODELAB)