상태관리라는 말이 처음엔 거창하게 들렸다. 무슨 대단한 기술 이름 같아서 겁부터 먹었는데, 막상 파고들어 보니 별게 아니었다. 어떤 값을 어디에 둘지, 그리고 그 값을 누가 바꾸고 누가 읽을지를 정하는 일이 전부다. 그런데 이걸 대충 넘긴 채로 리액트를 쓰면, useState는 분명 쓸 줄 아는데 화면이 왜 안 바뀌는지, 왜 엉뚱하게 바뀌는지 계속 얻어맞는다. 나도 한동안 그랬다. 상태를 어디에 둬야 할지 감이 없으니 일단 손 가는 대로 박아놓고, 나중에 값이 꼬일 때마다 땜질하는 식이었다. 그래서 상태관리를 배우기 전에, 대체 상태(state, 컴포넌트가 들고 있는 변하는 값)라는 게 뭔지부터 못을 박고 가야 한다. 여기가 흔들리면 뒤가 다 흔들린다.
상태는 변하는 값이다. 그런데 변한다고 다 상태는 아니다. 상태의 정의를 한 줄로 줄이면 이렇다. 시간이 지나며 변하고, 그 변화가 화면에 반영돼야 하는 값. 두 조건이 다 붙어야 한다. 버튼을 누른 횟수, 입력창에 친 글자, 모달이 열렸는지 닫혔는지. 이런 건 사용자가 뭔가 하면 값이 바뀌고, 바뀌면 화면도 따라 바뀌어야 하니 상태다. 반대로 화면에 한 번 그려지고 끝나는 고정된 제목 문구 같은 건 변하지 않으니 상태가 아니다. 이 감각이 왜 중요하냐면, 상태가 아닌 걸 상태로 만들어 두면 그때부터 관리할 게 쓸데없이 늘어나기 때문이다.
일반 변수로는 화면이 안 바뀐다. 리액트를 갓 시작한 사람이 제일 먼저 밟는 지뢰다. 값이 변해야 하니까 그냥 평범한 변수를 쓰면 되지 않나 싶어서 이렇게 짠다.
function Counter() {
let count = 0;
function handleClick() {
count = count + 1;
}
return <button onClick={handleClick}>{count}</button>;
}
버튼을 아무리 눌러도 화면의 숫자는 0에 붙어 있다. 이유는 두 겹이다. 첫째, count를 바꿔도 리액트는 다시 그리라는 신호를 못 받는다. 화면을 새로 그리는 리렌더는 상태나 props가 바뀔 때만 예약되는데, 평범한 변수를 고치는 건 리액트가 지켜보지 않는 영역이라 아무 일도 안 일어난다. 둘째, 설령 다른 이유로 리렌더가 됐다 쳐도, 컴포넌트 함수는 그릴 때마다 처음부터 다시 실행된다. 그러면 let count = 0도 다시 실행되어 애써 올린 값이 0으로 초기화된다. 일반 변수는 렌더 사이에 값을 기억하지 못한다. 이 두 문제를 한 번에 푸는 도구가 바로 상태다.
useState는 두 가지 일을 대신 해준다. 같은 카운터를 상태로 바꾸면 이렇게 된다.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return <button onClick={handleClick}>{count}</button>;
}
이번엔 누를 때마다 숫자가 착착 오른다. useState가 앞의 두 문제를 정확히 메워주기 때문이다. 하나, 값을 리렌더 사이에도 붙들어 둔다. 컴포넌트 함수가 몇 번을 다시 실행되든 count는 초기화되지 않고 리액트가 바깥에 따로 보관해 둔 최신 값을 가져온다. 둘, setCount를 부르면 값을 바꾸는 동시에 화면을 다시 그리라고 예약한다. 그래서 상태를 다루는 규칙은 딱 하나로 좁혀진다. 값을 바꿀 땐 변수에 직접 대입하지 말고 반드시 setCount 같은 갱신 함수로 바꾼다. 이 선을 넘으면 화면이 안 따라온다.
state와 props는 헷갈리기 쉽지만 소유자가 다르다. 둘 다 컴포넌트가 화면을 그리는 재료라 초보 땐 뭉뚱그려진다. 결정적 차이는 누가 그 값을 소유하고 바꿀 권한이 있느냐다. 상태는 그 컴포넌트가 소유한 값이라 자기가 setState로 바꾼다. props는 부모가 내려준 값이라 자식 입장에선 읽기 전용이다.
function Parent() {
const [name, setName] = useState("철수");
return <Child name={name} />;
}
function Child({ name }) {
return <p>{name}</p>;
}
여기서 name은 Parent의 상태다. Parent만 setName으로 바꿀 수 있다. Child는 그 값을 props로 받아 화면에 뿌리기만 할 뿐, 자기가 함부로 고쳐선 안 된다. 자식이 props를 억지로 바꾸려 드는 순간 데이터 흐름이 꼬인다. 값은 위에서 아래로만 흐른다는 게 리액트의 대원칙이고, 상태와 props의 구분이 그 흐름의 출발점이다. 상태는 값이 태어나 사는 집이고, props는 그 값이 아래층으로 배달되는 택배라고 생각하면 얼추 맞다.
무엇을 상태로 둘지가 진짜 실력이다. 계산되는 값은 상태가 아니다. 여기서부터가 상태관리의 시작이다. 초보가 제일 흔하게 저지르는 과잉이, 이미 있는 값으로 계산해 낼 수 있는 걸 굳이 별도 상태로 또 만드는 것이다. 이름과 성을 따로 받아 전체 이름을 보여주는 화면을 이렇게 짜는 식이다.
const [first, setFirst] = useState("");
const [last, setLast] = useState("");
const [fullName, setFullName] = useState("");
얼핏 멀쩡해 보이는데, fullName을 상태로 둔 순간 지옥문이 열린다. first가 바뀔 때마다, last가 바뀔 때마다 fullName도 손으로 맞춰 갱신해 줘야 한다. 한 군데라도 빠뜨리면 이름은 바뀌었는데 전체 이름은 옛날 값이 남는 어긋남이 생긴다. 나도 이런 식으로 짰다가, 성만 고쳤는데 화면엔 반영이 안 돼서 한참 애먼 곳을 뒤진 적이 있다. 이런 걸 파생 상태(derived state, 다른 상태로부터 계산되는데 굳이 따로 저장한 값)라 부르고, 리액트에선 안티패턴이다. 답은 간단하다. 저장하지 말고 렌더할 때마다 그 자리에서 계산한다.
const [first, setFirst] = useState("");
const [last, setLast] = useState("");
const fullName = first + " " + last;
fullName은 그냥 일반 상수다. 컴포넌트가 다시 그려질 때마다 최신 first와 last로 새로 계산되니, 동기화라는 개념 자체가 사라진다. 어긋날 여지가 없다. 원본이 되는 최소한의 값만 상태로 들고, 나머지는 그때그때 계산해 낸다. 이게 상태를 적게 유지하는 핵심이다.
상태는 적을수록 좋다. 정리하면 이렇다. 어떤 값을 상태로 만들까 고민될 땐 스스로 이렇게 물으면 된다. 이 값이 시간에 따라 변하나, 그 변화가 화면에 보여야 하나, 그리고 지금 있는 다른 값으로 계산해 낼 수는 없나. 앞의 둘이 참이고 마지막이 거짓일 때에만 상태로 만든다. 계산으로 뽑아낼 수 있으면 상태로 만들지 말고 계산한다. 상태가 늘어날수록 서로 어긋나지 않게 맞춰야 할 짝이 늘고, 버그가 숨을 구석도 늘어난다. 좋은 리액트 코드는 상태가 많은 코드가 아니라 꼭 필요한 상태만 가진 코드다. 상태가 무엇인지 손에 잡혔으면, 이제 그 상태를 어디에 둘지를 볼 차례다.