자바스크립트만 짤 때는 변수 값을 바꾸고 나면 화면을 다시 그리는 건 온전히 내 몫이었다. 값을 고치고, innerHTML을 다시 박고, 클래스를 갈아 끼우고. 하나라도 빠뜨리면 데이터는 바뀌었는데 화면은 옛날 그대로 남아 사람을 헷갈리게 했다. React는 이 관계를 뒤집는다. 값이 바뀌면 화면은 알아서 다시 그려진다. 단, 그 값을 그냥 변수가 아니라 상태(state)로 관리했을 때만이다.
오늘 다룰 도구는 딱 하나, useState라는 훅(hook)이다. 이름은 거창해 보여도 하는 일은 단순하다. 값을 하나 기억해 두고, 그 값이 바뀌면 화면을 다시 그리라고 React에 알려주는 것이다.
그냥 변수로는 화면이 안 바뀐다. 버튼을 누르면 숫자가 올라가는 카운터를 만든다고 하자. 상태를 모르는 채로 짜면 십중팔구 이렇게 쓴다.
function Counter() {
let count = 0;
return (
<button onClick={() => { count = count + 1; }}>
{count}
</button>
);
}
버튼을 눌러도 화면의 숫자는 0에서 꿈쩍도 안 한다. count 값 자체는 올라가지만, React 입장에서는 화면을 다시 그릴 이유를 못 받았기 때문이다. 게다가 컴포넌트가 어쩌다 다시 실행되면 let count = 0이 또 돌아 값은 도로 0으로 초기화된다. 평범한 변수는 렌더 사이에 기억되지도, 화면을 갱신하지도 않는다.
useState로 바꾸면 그제야 산다. 같은 카운터를 상태로 다시 쓰면 이렇게 된다.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
이제 버튼을 누르면 화면의 숫자가 실제로 0, 1, 2로 올라간다. useState(0)은 초기값 0으로 상태를 하나 만들고, 길이 2짜리 배열을 돌려준다. 앞은 현재 값(count), 뒤는 그 값을 바꾸는 함수(setCount)다. 배열 구조분해로 이름은 내 마음대로 붙이지만, 관례는 x와 setX 꼴이다. 핵심은 setCount를 부르는 순간 React가 "값 바뀌었으니 다시 그려라"라는 신호로 받아들인다는 것이다.
상태를 직접 건드리면 안 된다. 초보가 제일 먼저 저지르는 실수가 이거다. 값을 바꾸겠다고 상태 변수에 직접 대입하거나 증감 연산자를 갖다 대는 것이다.
count = count + 1; // 이렇게 하면 화면 안 바뀐다
count++; // 이것도 마찬가지
setCount(count + 1); // 반드시 setter를 거쳐야 한다
앞의 두 줄은 값을 바꾸긴 해도 React에 아무 신호를 못 준다. 상태는 setter를 통해서만 바꾼다고 통째로 외워두는 게 편하다. 나도 처음엔 "값만 바뀌면 되는 거 아냐?"라며 직접 대입했다가, 화면이 왜 안 움직이는지 삼십 분을 날렸다. React가 화면을 다시 그리는 유일한 방아쇠가 setter 호출이다.
이전 값을 기준으로 바꿀 땐 함수를 넘겨라. 버튼 한 번에 값을 두 번 올리고 싶어서 이렇게 짜는 사람이 있다.
setCount(count + 1);
setCount(count + 1);
// 기대: 2 증가 / 실제: 1만 증가
두 줄 다 그 순간의 count(예를 들어 0)를 보고 있어서 둘 다 1을 만든다. 결과는 2가 아니라 1이다. 한 번의 이벤트 안에서 count 값은 고정되어 있기 때문이다. 이럴 땐 값 대신 함수를 넘긴다.
setCount(c => c + 1);
setCount(c => c + 1);
// 실제: 2 증가
이렇게 하면 setter가 "직전 값을 받아서 1을 더한 값으로 갱신"하라는 뜻이 된다. React가 순서대로 이전 값을 넘겨주니 0이 1이 되고, 그 1이 2가 된다. 이전 상태에 기대는 갱신이라면 함수형으로 쓰는 걸 습관으로 두면 이런 함정에 안 빠진다.
객체 상태는 새 객체로 통째로 바꾼다. 상태가 객체일 때 안에 있는 속성만 슬쩍 고치고 싶은 유혹이 든다. 하지만 직접 고치면 화면이 안 바뀐다.
const [user, setUser] = useState({ name: "kim", age: 20 });
user.age = 21; // 화면 안 바뀐다
setUser({ ...user, age: 21 }); // 이렇게 새 객체를 만든다
React는 이전 상태와 새 상태가 같은 객체인지 아닌지로 갱신 여부를 판단한다. user.age = 21은 같은 객체를 그대로 두고 속을 바꾼 거라, React가 보기엔 달라진 게 없다. 그래서 스프레드(...user)로 기존 값을 복사한 새 객체를 만들고 바꿀 속성만 덮어써야 한다. 이걸 불변 업데이트(immutable update)라고 부른다. 배열도 똑같다. 항목을 넣을 땐 setList([...list, 새항목]), 뺄 땐 setList(list.filter(...))처럼 늘 새 배열을 만든다. list.push(...)로 원본을 밀어 넣으면 화면은 옛날 그대로 남는다.
상태는 필요한 만큼 여러 개 둔다. 컴포넌트가 상태를 하나만 가져야 하는 건 아니다. 서로 상관없는 값이면 따로 두는 편이 읽기 쉽다.
const [name, setName] = useState("");
const [age, setAge] = useState(0);
const [agreed, setAgreed] = useState(false);
이름과 나이와 동의 여부를 굳이 한 객체로 묶을 이유가 없다. 각각 따로 두면 바꿀 때 그 setter만 부르면 되고, 스프레드로 나머지를 복사하는 수고도 줄어든다. 반대로 늘 함께 움직이는 값들(예를 들어 폼 하나)이라면 객체 하나로 묶는 게 나을 때도 있다. 정답은 없고, 같이 바뀌면 묶고 따로 놀면 나눈다는 감만 있으면 된다.
입력창은 상태와 한 몸으로 묶는다. React에서 입력창을 다루는 정석은, 입력값을 상태에 담고 그 상태를 다시 입력창에 꽂는 것이다.
function NameInput() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={e => setName(e.target.value)}
/>
);
}
타이핑할 때마다 onChange가 setName을 부르고, 바뀐 name이 value로 돌아 들어와 화면에 찍힌다. 값을 React가 쥐고 있다고 해서 이런 입력창을 제어 컴포넌트(controlled component)라고 부른다. 입력한 대로 바로 화면에 보이는 이유가, 사실은 상태가 한 바퀴 돌아 나온 결과인 셈이다.
훅은 컴포넌트 맨 위에서만 부른다. useState는 아무 데서나 부르면 안 된다. 조건문이나 반복문 안에 넣는 순간 규칙 위반이다.
if (loggedIn) {
const [x, setX] = useState(0); // 이러면 안 된다
}
React는 훅을 부른 순서로 어느 상태가 누구인지 기억한다. 그런데 조건에 따라 부르는 횟수가 렌더마다 달라지면 그 순서가 어긋나 상태들이 뒤엉킨다. 그래서 useState는 늘 함수 본문 맨 위, 조건과 반복 바깥에서 부른다. 규칙이 빡빡해 보여도 이 하나만 지키면 훅이 꼬여 나는 괴상한 버그는 구경할 일이 없다.
상태는 컴포넌트의 기억이다. 정리하면 useState는 두 가지를 동시에 해준다. 렌더가 몇 번 다시 돌아도 값을 잃지 않게 기억해 주고, setter를 부르면 그 값을 반영해 화면을 다시 그려준다. 그러니 규칙은 셋뿐이다. 바꿀 값은 상태로 두고, 바꿀 땐 setter로만 부르고, 객체와 배열은 늘 새것으로 만든다. 이 세 가지만 몸에 배면 화면이 왜 안 바뀌는지 밤새우는 일은 거의 사라진다.