
처음 리액트를 학습할 때, 가장 생소했던 개념이 있었다.
바로 '상태(state)'였는데, 기존에 알던 '변수(variable)'와는 비슷하면서도 전혀 다르게 동작하는 것이 너무도 낯설게 느껴졌었다.
지금이야 상태를 밥 먹듯이 선언하고 사용하고 있는 상태이지만, 막상 상태에 대해 자세히 설명하라면 얼마나 깊이 있는 대답을 할 수 있을까?
이번 챕터를 통해 상태의 동작 원리, 구조 등에 대해 깊이 있게 학습해보고자 한다.
우리는 Javascript에서 특정 값을 불러오고 해당 값을 변화시키기 위해 var, let과 같은 변수를 사용하였다.
하지만 리액트에서는 변수들만으로 원하는 로직을 모두 구현할 수가 없다.
그 이유는 지역 변수의 2가지 특징 때문이다. (전역 변수를 사용하면 안되는 이유는 지난 챕터 '사이드 이펙트' 부분을 확인하세요!)
지역 변수는 렌더링 간에 유지되지 않는다.
리액트는 지역 변수를 변경해도 렌더링을 일으키지 않는다.
값의 변화를 감지하고 그에 따라 렌더링과 같은 인터랙션을 주어야하는 리액트의 입장에서는 여간 난처하지 않을 수 없다.
이와 같은 이유로, 리액트는 state라는 개념을 활용해 렌더링 사이에 데이터를 유지하고 새로운 데이터로 컴포넌트를 렌더링한다.
useState는 다음 두 개의 값을 포함하는 배열을 제공한다.
저장한 값을 가진 state 변수
state 변수를 업데이트하고 react에 컴포넌트를 다시 렌더링하도록 유발하는 state setter 함수
작동 방식은 다음과 같다.
컴포넌트 최초 렌더링 -> state 초기값 할당 -> setState로 새로운 state 할당 -> 컴포넌트 리렌더링
컴포넌트 리렌더링 시에는 state에 초기값을 할당하지 않는다. 따라서 props로 내려준 값을 state의 초기값으로 사용할 때 주의! (참고: https://www.philly.im/blog/putting-props-to-use-state)
리액트의 렌더링과 브라우저의 DOM의 렌더링은 완전히 동일한 의미일까?
적어도 나는 지금까지 막연히 그렇게 받아들이고 있었다.
그러나 리액트의 렌더링은 다음 일련의 과정을 거친다.
렌더링 트리거 -> 컴포넌트 렌더링 -> DOM에 커밋
즉, DOM에 있는 요소를 직접 변경하는 것이 아닌 컴포넌트(함수)를 렌더링하고 해당 함수가 생성한 DOM 노드를 화면에 표시한다.
그렇다면 리액트가 컴포넌트를 재렌더링할 때, State는 어떻게 동작할까?
state는 해당 컴포넌트가 아닌 컴포넌트 외부 리액트 자체에 존재한다.
그렇다면 컴포넌트 외부 어디에 존재할까? 바로 렌더트리의 위치에 연결된다. (참고: https://ko.react.dev/learn/preserving-and-resetting-state)
리액트가 컴포넌트를 호출하면, 특정 렌더링에 대한 state의 스냅샷을 제공하고 컴포넌트는 해당 렌더링의 state 값을 사용해 계산된 UI 스냅샷을 JSX에 반환한다.
비동기적으로 state를 사용하게 되더라도 해당 state를 불러온 시점의 snapshot이 전달된다는 점!
당연하게도 리액트는 단순히 모든 setState가 호출될 때마다 리렌더링을 하지 않는다.
만약 하나의 이벤트 핸들러에 여러 setState가 묶여있다면 모든 setState 호출이 완료된 이후에 리렌더링이 일어나게 된다.
또한 여러개의 setStateAction은 큐에 추가되어 동작한다.
// 생각보다 별 로직이 없어서 놀라웠던 챌린지 코드
export function getFinalState(baseState, queue) {
let finalState = baseState;
queue.forEach((el) => {
if (typeof el === "function") {
return (finalState = el(finalState));
}
return (finalState = el);
});
return finalState;
}
지난 글에 작성된대로, react는 참조 비교를 하기 때문에 state가 변하는지 알기 위해서는 state의 참조값이 바뀌어야한다.
따라서 객체/배열 state를 업데이트 하기 위해서는 새 객체/배열을 만들고 해당 객체/배열을 state에 set 해주어야한다.
너무 복잡한(중첩된) 객체를 만난다면? Immer를 사용해 간결화해보자!
// https://ko.react.dev/learn/updating-objects-in-state#challenges 챌린지 코드
...
export default function Canvas() {
// const [shape, setShape] = useState({
// color: 'orange',
// position: initialPosition
// });
// function handleMove(dx, dy) {
// shape.position.x += dx;
// shape.position.y += dy;
// }
// function handleColorChange(e) {
// setShape({
// ...shape,
// color: e.target.value
// });
// }
const [shape, updateShape] = useImmer({
color: 'orange',
position: initialPosition
});
function handleMove(dx, dy) {
updateShape(draft => {
draft.position.x += dx;
draft.position.y += dy;
})
}
function handleColorChange(e) {
updateShape(draft => {
draft.color = e.target.value
});
}
...
}
마찬가지로, 배열도 Immer를 활용해 보다 더 간결하게 코드를 수정할 수 있다.
// https://ko.react.dev/learn/updating-arrays-in-state#challenges 챌린지 코드
...
export default function TaskApp() {
const [todos, setTodos] = useState(initialTodos);
// function handleAddTodo(title) {
// todos.push({
// id: nextId++,
// title: title,
// done: false,
// });
// }
// function handleChangeTodo(nextTodo) {
// const todo = todos.find((t) => t.id === nextTodo.id);
// todo.title = nextTodo.title;
// todo.done = nextTodo.done;
// }
// function handleDeleteTodo(todoId) {
// const index = todos.findIndex((t) => t.id === todoId);
// todos.splice(index, 1);
// }
const [todos, updateTodos] = useImmer(
initialTodos
);
function handleAddTodo(title) {
updateTodos(draft => {
draft.push({
id: nextId++,
title: title,
done: false
});
});
}
function handleChangeTodo(nextTodo) {
updateTodos(draft => {
const todo = draft.find(t =>
t.id === nextTodo.id
);
todo.title = nextTodo.title;
todo.done = nextTodo.done;
});
}
function handleDeleteTodo(todoId) {
updateTodos(draft => {
const index = draft.findIndex(t =>
t.id === todoId
);
draft.splice(index, 1);
});
}
}