자바스크립트만 짤 때는 변수 값을 바꾸고 나면 화면을 다시 그리는 건 온전히 내 몫이었다. 값을 고치고, 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)다. 배열 구조분해로 이름은 내 마음대로 붙이지만, 관례는 xsetX 꼴이다. 핵심은 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가 순서대로 이전 값을 넘겨주니 01이 되고, 그 12가 된다. 이전 상태에 기대는 갱신이라면 함수형으로 쓰는 걸 습관으로 두면 이런 함정에 안 빠진다.


객체 상태는 새 객체로 통째로 바꾼다. 상태가 객체일 때 안에 있는 속성만 슬쩍 고치고 싶은 유혹이 든다. 하지만 직접 고치면 화면이 안 바뀐다.


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


타이핑할 때마다 onChangesetName을 부르고, 바뀐 namevalue로 돌아 들어와 화면에 찍힌다. 값을 React가 쥐고 있다고 해서 이런 입력창을 제어 컴포넌트(controlled component)라고 부른다. 입력한 대로 바로 화면에 보이는 이유가, 사실은 상태가 한 바퀴 돌아 나온 결과인 셈이다.


훅은 컴포넌트 맨 위에서만 부른다. useState는 아무 데서나 부르면 안 된다. 조건문이나 반복문 안에 넣는 순간 규칙 위반이다.


if (loggedIn) {
const [x, setX] = useState(0); // 이러면 안 된다
}


React는 훅을 부른 순서로 어느 상태가 누구인지 기억한다. 그런데 조건에 따라 부르는 횟수가 렌더마다 달라지면 그 순서가 어긋나 상태들이 뒤엉킨다. 그래서 useState는 늘 함수 본문 맨 위, 조건과 반복 바깥에서 부른다. 규칙이 빡빡해 보여도 이 하나만 지키면 훅이 꼬여 나는 괴상한 버그는 구경할 일이 없다.


상태는 컴포넌트의 기억이다. 정리하면 useState는 두 가지를 동시에 해준다. 렌더가 몇 번 다시 돌아도 값을 잃지 않게 기억해 주고, setter를 부르면 그 값을 반영해 화면을 다시 그려준다. 그러니 규칙은 셋뿐이다. 바꿀 값은 상태로 두고, 바꿀 땐 setter로만 부르고, 객체와 배열은 늘 새것으로 만든다. 이 세 가지만 몸에 배면 화면이 왜 안 바뀌는지 밤새우는 일은 거의 사라진다.