JavaScript/React

State: A Component’s Memory | 공식 문서, 책으로 useState 알아보기

남희정 2024. 3. 10. 18:17

최근 실무에서 상태 관리를 위해 Zustand docs와 상태 관리와 관련한 책을 들여다보다 문득 상태 관리를 하려면 상태에 대해서 알아야되는 것 아닌지, 이미 상태에 대해 잘 안다고 단언할 수 있는가하고 스스로 의심하게 되었다. 제일 기본적인 useState도 제대로 알고 쓰는지 확신할 수 없었다. 리액트 공식 문서와 리액트 딥다이브, 리액트 훅을 활용한 마이크로 상태관리 등의 책을 참고하여 상태를 알아보고자 한다.

참고로 원문을 직접 번역해서 적었기 때문에 부족한 부분이 있을 수 있고 그렇기에 react-ko.dev 내용과 차이가 있을 수 있다.

 


 

State 상태

컴포넌트는 상호작용의 결과에 따라 화면에 표시되는 내용을 변경해야하는 경우가 잦다. form에 입력할 때, input field를 업데이트해야 하고, 이미지 슬라이드에서 Next 버튼을 클릭할 때 표시되는 이미지들을 변경해야하고, 구매 버튼을 클릭 할 경우엔 장바구니에 상품이 담겨야 한다. 이처럼 컴포넌트는 기억해야한다. ▶︎ 현재 입력 값, 현재 이미지, 장바구니 등.

 

리액트에서 상태 State 사용자 인터페이스(UI)를 나타내는 모든 데이터를 말한다. 상태는 시간이 지남에 따라 변할 수 있고, 리액트는 상태와 함께 렌더링할 컴포넌트를 처리한다.

 

왜 필요하지? 

When a regular variable isn’t enough

일반 변수로 충분하지 않을 때

 

import { sculptureList } from './data.js'; // data.js 코드는 생략한다.

export default function Gallery() {
  let index = 0;

  function handleClick() {
    index = index + 1;
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i> 
        by {sculpture.artist}
      </h2>
      <h3>  
        ({index + 1} of {sculptureList.length})
      </h3>
      <img 
        src={sculpture.url} 
        alt={sculpture.alt}
      />
      <p>
        {sculpture.description}
      </p>
    </>
  );
}

 

Next 버튼을 눌러도 화면은 리렌더링되지 않는다. console.log를 찍고보니 index는 변경되지만 그 index에 맞춰서 화면이 그려지지 않는 것이 문제였다. 🤔

=> handleClick event handler가 local variable지역 변수인 index를 업데이트하고 있다. 하지만 두 가지 이유로 인해 변경사항이 표시되지 않는다. 

 

  1. 지역 변수는 영속적이지 않다. 두 번째로 렌더링될 때 변수에 대한 변경사항을 고려하지 않고 처음부터 렌더링한다. 마치 책을 닫았다가 다시 열 때마다 책갈피가 원래 페이지로 돌아가는 것처럼 컴포넌트가 새로 렌더링될 때마다 index 변수는 초기 상태로 돌아간다. 페이지를 넘겼던 기억이 사라져버리는 것이다!
  2. 지역 변수를 변경해도 렌더링이 트리거 되지 않는다. 즉, 화면이 새로고침되지 않는다. 리액트는 새로운 데이터로 컴포넌트를 다시 렌더링해야된다는 걸 인식하지 못한다. 책갈피를 옮겼다고 해서 책의 내용이 바로 업데이트되지 않는 것처럼 index를 변경해도 감지하지 못하는 것과 같다. 

새 데이터로 컴포넌트를 업데이트하려면 두 가지 일이 일어나야 한다.

 

  1. 렌더링 사이에도 데이터를 유지되게 한다.
  2. 리액트를 트리거하여 컴포넌트를 새 데이터로 리렌더링되게 한다.

이를 위해 상태가 있는 것..! 😮

useState

상태를 정의하고 상태를 관리할 수 있게 해주는 훅

 

훅 Hook
리액트에서는 useState를 비롯하여 use로 시작하는 function을 훅Hook이라고 부른다. (훅을 언급할 때 가장 먼저 떠올리는 것이 useState이다.) 훅은 리액트가 렌더링될 때만 사용할 수 있는 특별한 function이다. 다양한 리액트 기능에 연결hook into할 수 있게 해준다.

⚠️ 훅은 컴포넌트 최상위 레벨이나 커스텀 훅에서만 호출할 수 있다. 조건문, 반복문, 중첩 함수 내부에 호출할 수 없다. 훅은 function이지만 컴포넌트의 필요에 대한 무조건적인 선언으로 생각하면 도움이 된다. 파일 상단에서 모듈을 가져오는import 것과 유사하게 컴포넌트 상단에서 리액트 기능을 사용use한다.

 

 

useState 훅은 두 가지를 제공한다.

 

  1. 리렌더링 되더라도 데이터를 유지하기 위한 State variable 상태 변수
  2. 변수를 업데이트하고 리액트가 컴포넌트를 리렌더링하도록 트리거하는 State Setter function 상태 설정자 함수

useState를 통해 개발자는 컴포넌트의 상태를 쉽게 관리하고, 사용자와의 상호작용이나 다른 변화에 반응해서 화면을 자동으로 업데이트할 수 있다. 이를 기반으로 더 복잡한 사용자 정의 훅도 만들 수 있다..! (나중에 커스텀 훅에 대해서도 다뤄보겠다)

 

Adding a state variable

위의 코드에 useState를 적용해서 원하는 대로 작동하도록 바꿔보자.

상태 변수를 추가하기 위해 import useState를 해줘야한다.

import { useState } from 'react';

 

위의 코드에서 `let index = 0`으로 해놓은 부분을 고쳐준다.

const [index, setIndex] = useState(0);

 

여기서 index는 상태 변수이고 setIndex는 setter function이다.

 [ ] 를 사용하는데, 배열 구조 분해 array destructuring 를 통해 useState가 반환하는 배열에서 첫 번째 요소와 두 번째 요소를 꺼내어 값을 읽을 수 있게 해준다. (아래에 좀 더 자세히 설명한다.)

 

handleClick도 변경해준다.

function handleClick() {
  setIndex(index + 1);
}

 

import { useState } from 'react';
import { sculptureList } from './data.js';

export default function Gallery() {
  const [index, setIndex] = useState(0);

  function handleClick() {
    setIndex(index + 1);
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleClick}>
        Next
      </button>
      <h2>
        <i>{sculpture.name} </i> 
        by {sculpture.artist}
      </h2>
      <h3>  
        ({index + 1} of {sculptureList.length})
      </h3>
      <img 
        src={sculpture.url} 
        alt={sculpture.alt}
      />
      <p>
        {sculpture.description}
      </p>
    </>
  );
}

 

 

변경된 코드로 useState를 제대로 파헤쳐보자 🧐 

 

useState를 호출한다는 건 이 컴포넌트가 무언갈 기억하길 원한다는 뜻이다. 위의 경우엔 index를 기억하는 것이겠다.

주로 const [something, setSomething] 처럼 지정하는게 일반적이다.

 

useState의 유일한 인수는 state variable의 초기값 initial value이다. 여기선 0으로 설정되었다.

컴포넌트가 렌더링될 때마다 useState는 두 개의 값을 포함하는 배열을 제공한다. 

 

  1. State variable 상태 변수 => index
  2. State Setter function 상태 설정자 함수 => setIndex

useState 작동 방식 🧐

  1. 컴포넌트가 처음으로 렌더링된다.
    index의 초기값으로 useState에 0을 전달했으니 [0, setIndex]를 반환한다. 리액트는 0이 최신 상태 값임을 기억한다.
  2. state를 업데이트 한다. 사용자가 버튼을 클릭하면, setIndex(index+1)를 호출한다. 
    index가 0이므로 setIndex(1)이 된다. 리액트는 index가 이제 1임을 기억하고 다음 렌더링을 트리거 한다.
  3. 컴포넌트가 두 번째로 렌더링된다. 리액트는 여전히 useState(0)을 보지만, index를 1로 설정한 것을 기억하기에 [1, setIndex]를 대신 반환한다.
  4. 이렇게 계속된다!

State Setter function이 왜 필요한가?

useState로 상태 값 업데이트를 위해 우리는 계속 새로운 값을 제공했다. useState가 반환하는 함수에 새로운 값을 전달해서 기존 상태 값이 새로운 값으로 대체되는 작동방식을 확인했다. 새로운 값이 아니라, 동일한 값이라면 아까 초기에 js로 구현했던 것처럼 아무런 변화가 없다는 건가?

 

const Component = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      {count}
      <button onClick={() => setCount(1)}>Set Count to 1</button>
    </div>
  );
};

 

0에서 setCount(1)을 한 번 호출한 이후 다시 호출하면 동일한 값이기 때문에 화면에선 차이가 없는 것을 확인할 수 있다. 동일한 값이면 베일아웃되어 컴포넌트가 다시 렌더링 되지 않는다. 베일아웃 Bailout은 리액트 기술 용어로, 리렌더링을 발생시키지 않는 것을 의미한다. 

setCount와 같은 갱신 함수의 역할이 왜 중요한지 알 수 있다. 대부분의 경우 갱신 함수는 이전 값을 기반으로 갱신하는 경우에 유용하다. 

 

const Component = () => {
  const [count, setCount] = useState(0);
  return (
    <div>
      {count}
      <button onClick={() => setCount((c) => c + 1)}>increment Count</button>
    </div>
  );
};

 

(c) => c+1 이 호출되면서 실제로 버튼을 클릭한 횟수를 센다. 이전 예제의 Set Count to {count + 1} 예제는 이전 값에 기반하지 않고 화면에 표시된 값에 기반한다.

초기값에 대하여 / 지연 초기화

useState는 첫 번째 렌더링에만 평가되는 초기화 함수를 받을 수 있다. 위에선 초기값을 0으로만 설정했는데  이외에도 무거운 계산을 포함할 수 있고 초기 상태를 가져올 때만 호출된다. useState가 호출되기 전까지 초기값은 평가되지 않고 느리게 평가된다. 즉, 컴포넌트가 마운트 Mount 될 때 한 번만 호출된다. 

 

=> 이를 느긋한 계산법 혹은 지연 평가 lazy evalution라 한다. 보편적으로 필요한 시점에 계산되는 것을 말한다. 

 

  1. 직접 초기값 지정하기 : 위처럼 0으로 직접적으로 지정하는 경우를 말한다.
  2. 지연 초기화 사용하기 : 초기 상태를 설정하기 위한 함수를 useState에 전달할 수 있다. 
    useState(()=> somExpensiveComputation()) 와 같이 사용할 수 있다. 비용이 많이 드는 계산을 수행하는 함수로, 이 방법으로 컴포넌트가 렌더링 될 때까지 계산을 미루게 되어 성능을 최적화할 수 있다. 

상태 변수의 갯수, 한계가 있을까? 

하나의 컴포넌트에 원하는 만큼 많은 유형의 상태 변수state variable를 가질 수 있다

 

import { useState } from "react";
import { sculptureList } from "./data.js";

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextClick() {
    setIndex(index + 1);
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  let sculpture = sculptureList[index];
  return (
    <>
      <button onClick={handleNextClick}>Next</button>
      <h2>
        <i>{sculpture.name} </i>
        by {sculpture.artist}
      </h2>
      <h3>
        ({index + 1} of {sculptureList.length})
      </h3>
      <button onClick={handleMoreClick}>
        {showMore ? "Hide" : "Show"} details
      </button>
      {showMore && <p>{sculpture.description}</p>}
      <img src={sculpture.url} alt={sculpture.alt} />
    </>
  );
}

 

index와 showMore처럼 서로 관련이 없는 경우 여러 개의 상태 변수를 사용하는 것이 좋다. => 하지만 두 개의 상태 변수를 자주 함께 변경하는 경우엔 하나로 결합하는 게 더 쉬울 수 있다. 

예로, 필드가 많은 form의 경우 필드 별 상태 변수보다 하나의 객체를 사용하는 단일 상태 변수를 사용하는 게 더 편하다.

 

그렇다면 리액트는 각각의 상태 변수를 어떻게 구별하지?

useState에 전달되는 identifier 식별자가 없는데 어떤 상태 변수를 반환할지 어떻게 알 수 있을까?
간결한 구문을 구현하기 위해 Hook은 동일한 컴포넌트의 모든 렌더링에서 안정적인 호출 순서에 의존한다. 최상위 수준에서만 훅 호출..이라는 규칙을 따르면 훅은 항상 같은 순서로 호출되기 때문에 잘 작동한다.

 

내부적으로 React는 모든 컴포넌트에 대해 한 쌍의 state 배열을 가진다. 렌더링 전에 0으로 설정된 현재 index를 유지한다. useState를 호출할 때마다 리액트는 다음 state 쌍을 제공하고 index를 증가시킨다. 

 

상태의 고립성과 비공개성

상태는 화면의 Component instance에 local이다. 즉, 동일한 컴포넌트를 두 번 렌더링하면 각 복사본은 완전히 분리된 상태를 갖게 된다. 그 중 하나를 변경해도 다른 컴포넌트에는 영향을 미치지 않는다.

Component Model
컴포넌트는 함수처럼 재사용 가능한 하나의 단위다. 컴포넌트를 한 번 정의하면 여러 번 사용하는 것이 가능하다. 이는 컴포넌트가 독립적인 경우에만 가능하다. 컴포넌트가 컴포넌트 외부에 의존하는 경우 동작이 일관되지 않을 수 있으므로 재사용이 불가능할 수 있다. 따라서 엄밀하게 말하면 컴포넌트 자체는 전역 상태에 가급적 의존하지 않는 것이 좋다.

 

리액트 훅 함수와 컴포넌트 함수는 여러 번 호출될 수 있기에 함수가 여러 번 호출되더라도 일관되게 동작할 수 있게 충분히 순수해야한다는 규칙이 있다.

Gallery 컴포넌트 두 개

 

모듈 상단에 선언할 수 있는 일반 변수와 state의 차이점이다. State는 특정 함수 호출이나 코드의 특정 위치에 묶여있지 않고 화면의 특정 위치에 Local로 존재한다.  그림에 <Gallery> 컴포넌트 두 개를 렌더링했으므로 해당 state는 별도로 저장된다.

=> 부모 컴포넌트는 Gallery의 state뿐 아니라 state가 있는지 여부 조차 알지 못한다. props와는 달리 state는 이를 선언하는 컴포넌트 외에 프라이빗하고 변경될 수 없다. 이런 특성 덕분에 다른 컴포넌트에 영향을 주지 않고 상태를 추가하거나 제거할 수 있다.

 

만약 이 두 컴포넌트의 state를 동기화하려면?

자식 컴포넌트에서 state를 제거하고 공유하는, 가장 가까운 부모 컴포넌트에 추가하는 것이 가장 올바른 방법이다.

 

Vanilla JS로 useState 만들어보기

공식 문서를 참고하여 바닐라 js로 useState를 만들어보았다. 

js로 useState 구현하기 test UI

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        font-family: sans-serif;
        margin: 20px;
        padding: 0;
      }
      button {
        display: block;
        margin-bottom: 10px;
      }
      .btn {
        display: flex;
        justify-content: space-between;
        margin-bottom: 20px;
      }
    </style>
  </head>
  <body>
    <div class="btn">
      <button id="prevButton">이전</button>
      <button id="nextButton">다음</button>
    </div>
    <h3 id="header"></h3>
    <button id="moreButton"></button>
    <p id="description"></p>
    <script src="./index.js"></script>
  </body>
</html>
let componentHooks = [];
let currentHookIndex = 0;

// useState js로 만들기
function useState(initialState) {
  let pair = componentHooks[currentHookIndex];
  if (pair) {
    // 첫 번째 렌더링이 아님 - 상태 쌍이 이미 존재함
    // 반환하고 다음 훅 호출을 준비함
    currentHookIndex++;
    return pair;
  }

  // 첫 번째 렌더링 - 상태 쌍을 생성하고 저장함
  pair = [initialState, setState];

  function setState(nextState) {
    // 사용자가 상태 변경을 요청할 때, 새 값을 쌍에 넣음.
    pair[0] = nextState;
    updateDOM();
  }

  // 미래의 렌더링을 위해 쌍을 저장 - 다음 훅 호출을 준비함.
  componentHooks[currentHookIndex] = pair;
  currentHookIndex++;
  return pair;
}

function Gallery() {
  // 각 useState() 호출은 다음 쌍을 얻음.
  const [index, setIndex] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handlePrevClick() {
    setIndex(index - 1);
  }

  function handleNextClick() {
    setIndex(index + 1);
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  let sculpture = sculptureList[index];
  // 이 예제는 React를 사용하지 않으므로, JSX 대신 출력 객체를 반환함.
  return {
    onPrevClick: handlePrevClick,
    onNextClick: handleNextClick,
    onMoreClick: handleMoreClick,
    header: `${sculpture.name} by ${sculpture.artist}`,
    counter: `${index + 1} of ${sculptureList.length}`,
    more: `${showMore ? "세부 사항 숨기기" : "세부 사항 보기"}`,
    description: showMore ? sculpture.description : null,
  };
}

function updateDOM() {
  // 컴포넌트를 렌더링하기 전에 현재 훅 인덱스를 재설정함.
  currentHookIndex = 0;
  let output = Gallery();

  // 출력에 맞게 DOM을 업데이트함. 이 부분은 React가 당신을 위해 처리함.

  prevButton.disabled = output.index === 0;
  prevButton.onclick = output.onPrevClick;
  nextButton.onclick = output.onNextClick;
  header.textContent = output.header;
  moreButton.onclick = output.onMoreClick;
  moreButton.textContent = output.more;
  if (output.description !== null) {
    description.textContent = output.description;
    description.style.display = "";
  } else {
    description.style.display = "none";
  }
}

let prevButton = document.getElementById("prevButton");
let nextButton = document.getElementById("nextButton");
let header = document.getElementById("header");
let moreButton = document.getElementById("moreButton");
let description = document.getElementById("description");
let sculptureList = [
  {
    name: "Homenaje a la Neurocirugía",
    artist: "Marta Colvin Andrade",
    description:
      "Although Colvin is predominantly known for abstract themes that allude to pre-Hispanic symbols, this gigantic sculpture, an homage to neurosurgery, is one of her most recognizable public art pieces.",
  },
  {
    name: "Floralis Genérica",
    artist: "Eduardo Catalano",
    description:
      "This enormous (75 ft. or 23m) silver flower is located in Buenos Aires. It is designed to move, closing its petals in the evening or when strong winds blow and opening them in the morning.",
  },
  {
    name: "Eternal Presence",
    artist: "John Woodrow Wilson",
    description:
      'Wilson was known for his preoccupation with equality, social justice, as well as the essential and spiritual qualities of humankind. This massive (7ft. or 2,13m) bronze represents what he described as "a symbolic Black presence infused with a sense of universal humanity."',
  },
  {
    name: "Moai",
    artist: "Unknown Artist",
    description:
      "Located on the Easter Island, there are 1,000 moai, or extant monumental statues, created by the early Rapa Nui people, which some believe represented deified ancestors.",
  },
  {
    name: "Blue Nana",
    artist: "Niki de Saint Phalle",
    description:
      "The Nanas are triumphant creatures, symbols of femininity and maternity. Initially, Saint Phalle used fabric and found objects for the Nanas, and later on introduced polyester to achieve a more vibrant effect.",
  },
  {
    name: "Ultimate Form",
    artist: "Barbara Hepworth",
    description:
      "This abstract bronze sculpture is a part of The Family of Man series located at Yorkshire Sculpture Park. Hepworth chose not to create literal representations of the world but developed abstract forms inspired by people and landscapes.",
  },
  {
    name: "Cavaliere",
    artist: "Lamidi Olonade Fakeye",
    description:
      "Descended from four generations of woodcarvers, Fakeye's work blended traditional and contemporary Yoruba themes.",
  },
  {
    name: "Big Bellies",
    artist: "Alina Szapocznikow",
    description:
      "Szapocznikow is known for her sculptures of the fragmented body as a metaphor for the fragility and impermanence of youth and beauty. This sculpture depicts two very realistic large bellies stacked on top of each other, each around five feet (1,5m) tall.",
  },
  {
    name: "Terracotta Army",
    artist: "Unknown Artist",
    description:
      "The Terracotta Army is a collection of terracotta sculptures depicting the armies of Qin Shi Huang, the first Emperor of China. The army consisted of more than 8,000 soldiers, 130 chariots with 520 horses, and 150 cavalry horses.",
  },
  {
    name: "Lunar Landscape",
    artist: "Louise Nevelson",
    description:
      "Nevelson was known for scavenging objects from New York City debris, which she would later assemble into monumental constructions. In this one, she used disparate parts like a bedpost, juggling pin, and seat fragment, nailing and gluing them into boxes that reflect the influence of Cubism’s geometric abstraction of space and form.",
  },
  {
    name: "Aureole",
    artist: "Ranjani Shettar",
    description:
      'Shettar merges the traditional and the modern, the natural and the industrial. Her art focuses on the relationship between man and nature. Her work was described as compelling both abstractly and figuratively, gravity defying, and a "fine synthesis of unlikely materials."',
  },
  {
    name: "Hippos",
    artist: "Taipei Zoo",
    description:
      "The Taipei Zoo commissioned a Hippo Square featuring submerged hippos at play.",
  },
];

// 초기 상태에 맞게 UI 업데이트.
updateDOM();

 

🤔💭

상태를 이해해보고 사용해보았다. useState를 사용하는 방법에 대해선 알고 있었지만 매커니즘을 정확하게 보려고 하진 않았던 나를 반성하며,,, 리액트 공식문서를 보며 열심히 번역해보았고 그에 따라 이전에 보았던 리액트 책들도 다시 꺼내어 펼쳐보았다. 아직도 완전히 이해했다곤.. 할 수 없다. useState에 대해선 좀 더 다뤄볼 예정이다. 이론으로 공부한 적 있지만 useReducer과 비교해보고 싶었고 스스로 그런 부분들을 선정해서 들여다 보고싶었다. Hook에 대한 부분도 깊게 들어가면 정말 다룰 것이 많아서.. 우선 주제에 벗어나지 않게 조절하려 했다. 스스로 무지함을 느끼고 공부한 부분이라 도움이 될지 가늠이 되지 않지만 내 글을 통해 상태와 좀더 가까워진 느낌을 받으셨다면 정말 기쁠 것 같다.  

 

[State: A Component's Memory]

[useState]

[React는 Hooks를 배열로 관리하고 있다]

[Vanilla Javascript로 React UseState Hook 만들기]

모던 리액트 Deep Dive 📚

리액트 훅을 활용한 마이크로 상태 관리 📚