앞선 글에서 리액트는 SPA를 만드는 라이브러리라고 했습니다. SPA는 페이지 이동없이 동적인 콘텐츠를 표시할 수 있는 어플리케이션입니다. SPA를 구현하기 위해서는 여러 콘텐츠를 사용자와의 상호작용에 따라 보여줄 수 있어야 하죠. 리액트는 훅이라고 불리는 것으로 동적인 값을 관리할 수 있습니다. '훅'이라는 용어는 함수형 컴포넌트 내에서 특정한 리액트 기능에 "연결(hook into)"하거나 액세스할 수 있는 개념을 나타냅니다. 리액트의 수 많은 'Hook' 중에서 가장 기초가 되는 Hook인 userState에 대해서 알아보겠습니다.

HOOK의 규칙
useState에 대해 자세히 알아보기 전에 모든 리액트 Hook들이 가지는 공통적인 규칙 먼저 알아보겠습니다.
리액트의 모든 Hook들은 컴포넌트의 최상위 수준 혹은 커스텀 훅에서만 호출할 수 있습니다. 조건문 또는 반복문의 내부 혹은 중첩된 함수 내부에서 사용이 불가능합니다. 그 이유는 리액트의 Hook은 각 Hook이 호출되는 순서에 의존하기 때문에 호출되는 순서가 달라지면 문제가 발생하기 때문이죠. 예를 들어 A, B, C, D라는 훅이 순서대로 호출되었다고 합시다. 만약 리렌더링 단계에서 B, A, C, D 순서로 호출된다면, B는 이전 A의 값을 A는 이전 B의 값을 읽어 버리게 되어 버그가 발생합니다.(출처 - legacy.reactjs, Hook의 규칙)

useState 사용법

리액트는 상태(State)를 통해서 콘텐츠를 관리합니다. 상태란 리액트에서 사용하는 데이터같은 것입니다. 이러한 상태들은 각각의 컴포넌트 내에서 자바스크립트 객체로 존재합니다. 컴포넌트의 상태가 변경되면 리액트의 상태관리 시스템이 컴포넌트를 다시 렌더링하여 변경된 콘텐츠를 사용자가 볼 수 있도록 합니다. 그리고 다음과 같이 useState를 통해 상태를 만들 수 있습니다.

import { useState } from 'react';

const SomeComponent = () => {
    const [someValue, setSomeValue] = useState(0)
}

 

사용법은 간단합니다. 리액트에서 useState를 import하고 컴포넌트 내부에서 사용하면 됩니다. 이 hook의 인자는 상태의 초기값입니다. 문자, 숫자, 객체... 어느것으로든 만들 수 있죠. useState는 두개의 요소가 들어있는 배열을 반환하는 데, 첫 번째 요소는 상태의 값을 나타내고 두 번째 요소는 함수로 상태를 변화시키는 함수입니다(setter 함수라고 생각하시면 이해하기 쉽습니다.).

상태 값

useState에는 낭비를 막기 위한 기능이 있습니다. 바로 최초 렌더링 시에만 인자로 들어간 기본값을 사용하고 이후에는 이 값을 무시한다는 것입니다. 만약 2번째 이후의 렌더링이라면 상태의 값은 useState의 인자로 들어가는 값이 아니라 현재의 렌더링 직전, 마지막으로 변경되었던 값이 이번 렌더링에 할당됩니다. 즉 useState는 컴포넌트가 렌더링 된 직후에 정해진 값을 스냅샷으로써 기억하는 역할을 하는 것이죠!

 

만약, usState의 기본 값으로 함수의 결과값을 넣어야한다면, 인자로 들어가는 함수를 실행하지 않고 그 함수를 가리키는 포인터를 넣는(함수의 참조를 전달는 것)이 더 나을 수 있습니다. 그러면 useState()의 인자로 들어간 함수가 매번 실행되지 않고 최초로 렌더링되었을 경우만 실행되어 성능을 개선할 수 있습니다. 만약 괄호를 붙여 함수를 실행시킨 결과를 useState에 사용하면 매번 해당 함수는 실행되지만 두 번째 이후의 렌더링에서는 useSatate가 그 값을 무시하기 때문에 불필요하게 함수가 실행되어 낭비가 됩니다.

 

만약 (거의 없겠지만)상태의 값으로 함수의 결과 값이 아닌 함수 자체로 설정하고 싶다면 다음과 같이 사용하면 됩니다. 

const [funcState, setFuncState] = useState(() => {myFunc})

// 상태를 업데이트 할 때,
const a = () => {
	setFuncState(() => {otherFunc})
}

 

리액트의 상태관리는 특수한 기능이 한 가지 더 있는데요. 바로 변화한 상태가 이전과 같은 값이라면 리렌더링을 하지 않습니다. 리액트는 콘텐츠를 사용자에게 표시하는 역할을 하는데, 상태의 업데이트가 발생했더라도 이전과 같은 내용을 표시해야한다면 굳이 다시 화면을 그리는 낭비를 할 필요가 없기 떄문이죠.

set 함수

두 번째 반환값(이하 set 함수)은 상태의 값을 변경하는 역할을 하는 함수가 됩니다. 관행적으로 set이라는 접두사를 붙여서 이것이 상태를 변경하는 함수라는 것을 알게해줍니다.

사용법은 다음과 같습니다.

setSomeValue(1) // someValue를 1로 설정합니다.

setSomeValue('A') // someValue를 A로 설정합니다.

setSomeValue(someValue + 1) // 작동하지만 권장되지 않는 방법
setSomeValue((someValue) => someValue + 1) // 권장되는 방법

 

이제 이 set 함수를 가지고 상태를 원하는대로 변화시킬 수 있습니다. 다만 몇 가지 규칙(주의사항)을 지킨다면 말이죠.

set함수의 규칙

그렇다면 useState는 어떤 규칙이 있을까요?

상태의 값을 직접 변경해서는 안된다.

useState의 반환값의 첫 번째 요소는 상태의 값이라고 했습니다. 만약 이 값을 직접 변경하면 어떻게 될까요? 먼저 리액트가 변경을 감지하지 못하여 리렌더링이 일어나지 않습니다. 대신 useState의 두 번째 반환값인 함수를 사용하여 상태를 업데이트하여야 합니다. 이 함수는 리액트가 상태 변경을 감지하여 리렌더링을 할 수 있도록 만들어줍니다. 만약 상태의 값을 직접 변경하면 리액트는 해당 변경을 감지하지 못하여 리렌더링이 일어나지 않아 새로운 상태를 사용자가 확인할 수 없습니다.

 

상태의 값을 직접 변경해서는 안되는 두 번째 이유는 state는 읽기 전용이기 때문입니다. 애초에 수정하기 위해 만들어진 값이 아닙니다. 따라서 상태의 값이 객체인 경우라도 직접 수정해서는 안됩니다. 대신 자바스크립트의 스프레드 문법을 사용하여 새로운 객체를 생성한 뒤 set함수를 사용하세요.

setObj({
...obj,
somekey: newValue
})

이전 상태를 기반으로 업데이트하여야 한다면 함수를 통해 업데이트하라.

출처: React.dev state 업데이트 큐

setSomeValue(someValue + 1)

 

위와 값이 someValue라는 상태를 1 증가시키는 경우에는 이전 값을 참조하게 됩니다. 위의 코드는 (대부분의 경우에는 문제가 없을 수도 있지만) 잠재적으로 큰 문제가 될 가능성이 있습니다. 리액트의 상태 업데이트 스케줄링에 의해 상태는 즉각적으로 업데이트되지 않기 때문입니다. 리액트의 상태(여기서는 someValue)는 렌더링이 일어난 이후 정해진 값으로 고정되어 있습니다. 리렌더링이 일어나는 순간 그 값이 변화되죠. 따라서 SetSomeValue(someValue + 1)을 통해 somValue의 값을 변화시켜도 리렌더링이 일어나기 전까지 해당 변화는 반영이 되지 않습니다. 이 때문에 (억지스러운 예시이지만) 다음과 같은 상황에서 문제가 발생합니다.

setSomeValue(someValue + 1);
setSomeValue(someValue + 1);
setSomeValue(someValue + 1);

 

일반적으로 생각하기에는 someValue에 3이 더해져야 할 것같지만, 리렌더링이 일어난 후 확인하거나 각각의 함수 호출 이후 console.log(someValue)를 호출해 보면 someValue에는 1만 더해집니다. 해당 코드에서 someValue는 렌더링 된 이후 처음 설정된 값으로 고정되어 있기 때문입니다. someValue라는 state가 실제로 변하는 시점은 해당 컴포넌트가 리렌더링 되기 직전입니다. 만약 렌더링 된 시점에 someValue가 0이었다면 세 줄의 코드 모두 0 + 1을 연산하는 셈이 되는 거죠. 이 문제를 해결하기 위해서는 다음과 같이 사용하여야 합니다.

setSomeValue((prevValue) => prevValue + 1)
// 이해를 쉽게 하도록 preValue라는 (매개)변수를 사용했습니다.
// set 함수의 인자에 함수를 넣으면 첫번째 인자로 해당 함수가 관리하는 상태의 이전 값(함수 실행 직전 값)을 넣기 때문입니다.

 

위와 같이 함수를 통해 값을 업데이트 하게 되면 항상 최신의 값을 가져오는 것이 보장됩니다. 그 이유는 set함수의 인자로 함수를 넣게되면 값을 업데이트 하는 시점이 달라지기 때문입니다. setSomeValuef 자체는 즉시 실행되지만 그 내부에 있는 함수는 컴포넌트가 다시 렌더링하기 전에(정확하게는 컴포넌트를 재평가하는 단계)에 실행됩니다. someValue의 값이 변하는 단계와 set함수 내부의 함수가 실행되는 단계가 같은 시점인 리렌더링 직전에 수행되죠. 그래서 set함수 내부의 함수 실행 → 상태 변화 →  set함수 내부의 함수 실행 →  상태변화... 의 순서로 실행될 수 있습니다. 다시말해, set함수 내부의 함수는 항상 최신의 값을 받을 수 있습니다. 이 내용에 대해 더 많이 알고 싶으시다면 state 업데이트 큐를 읽어보세요.

 

물론 대부분의 경우에는 문제가 없습니다. 예를 들어 버튼 클릭 후 1증가하는 컴포넌트가 있다면, 다음 클릭은 리렌더링 이후에 발생하기 때문에 항상 올바른 값을 참조할 수 있습니다. 그리고 이전의 값을 이용한 업데이트가 아닌 경우는 최신의 값을 참조할 필요도 없습니다. 따라서 무조건 상태 업데이트를 위해 함수를 이용할 필요는 없습니다. 다만, 더 안전한 사용을 위해 함수를 이용한 업데이트를 권장합니다.

useState 초기값에 관한 이야기

초기값은 단 한 번만 지정되고 이 후의 렌더링에서는 렌더링 되기 이전에 컴포넌트를 재평가하면서 얻어진 값을 사용합니다. 이 때문에 초기값을 종종 dump 데이터를 넣듯이 처리하는 경우도 있는데요. 하지만 해당 상태에는 어떤 형식의 값이 들어가는 지 알려주는 중요한 역할을 하므로 가볍게 생각하는 것은 좋지 않습니다. 또한 로직의 편리성을 위해 실제로 들어가야 하는 값과 다르게 넣게 될 수도 있습니다.

 

예를 들어 input 폼이 빈 값이면 false값이 되고 입력 값이 있으면 true가 되는 isValid라는 상태가 있다고 합시다. isValid가 false면 유효한 값을 입력하라는 메세지가 나옵니다. 가장 처음 렌더링 되었을 때 폼이 빈값이므로 false가 되어야 하지만 이렇게 하면 해당 폼을 수정하라는 문구가 나오는 것이 보기 실어 폼이 빈값임에도 isValid를 true로 두는 등의 경우입니다. 큰 문제가 없어 보입니다. 원하는 대로 첫 렌더링 때 경고문구가 나오지 않습니다. 하지만 만약 isValid가 true면 폼의 제출이 가능한 로직이라면 렌더링 이후 폼이 비어있음에도 제출이 가능하게 됩니다. 따라서 기본값은 로직에 적절한 false로 설정하고 한 번이라도 입력이 되었는지를 확인하는 변수를 추가하여 경고문구를 관리하는 것이 바람직합니다.

 

 

참고 자료

react.dev [Hooks, useState, 스냅샷으로서의 State, state 업데이트 큐객체 State 업데이트하기]

legacy.reactjs, Hook의 규칙

+ Recent posts