본문 바로가기
FE/React hooks in action

2. useState 훅으로 컴포넌트 상태 관리하기 - 2

by Toddler_AD 2025. 9. 20.

2.2 useState를 사용해 값을 저장하고 사용하며 설정하기

  • 리액트 애플리케이션은 특정 상태를 관리한다. 이런 상태에는 사용자 인터페이스에 표시되거나 표시 대상을 관리할 때 도움이 되는 값을 포함한다. 예를 들어, 포럼의 포스트, 그 포스트의 댓글 및 댓글을 표시할 지 여부 등이 상태에 포함 될 수 있다. 사용자가 앱과 상호작용하면 상태가 바뀐다. 더 많은 포스트를 적재하거나 댓글 표시 여부를 전환하거나 새로 댓글을 추가할 수 있다. 리액트는 상태와 UI가 동기화되도록 보장한다. 상태가 변경되면 리액트가 해당 상태를 사용하는 컴포넌트를 실행할 필요가 있다. 컴포넌트는 최신 상태 값을 사용하는 UI를 반환한다. 리액트는 반환된 UI를 기존 UI와 비교해 필요에 따라 효율적으로 DOM을 갱신한다.
  • 일부 상태는 애플리케이션 전체에서 공유되며, 일부는 몇 가지 컴포넌트들 사이에 공유되며, 일부는 컴포넌트 자체에서 지역적으로 관리된다. 컴포넌트가 단순한 함수인 경우 상태를 어떻게 유지할 수 있을까? 지역 변수는 함수 실행이 끝날 때 소멸되지 않나? 리액트는 변수가 변경됐을 때 이를 어떻게 알 수 있을까? 리액트가 상태와 UI를 신뢰성 있게 일치시키려고 노력한다면, 리액트가 상태 변경을 반드시 알아야 하지 않나?
  • 컴포넌트 호출과 호출 사이에 상태를 유지하고 컴포넌트의 상태 변경을 리액트에 알리는 가장 간단한 방법은 useState 훅이다. useState 훅은 상태 관리를 위한 도움을 리액트에게 요청하는 함수다. useState 훅을 호출하면, 훅이 최신 상태 값을 반환하면서 값을 변경할 때 쓸 수 있는 함수(갱신 함수나 updater 함수 라고도 부른다)도 함께 반환한다. 이 갱신 함수를 사용하면 리액트가 루프를 돌면서 동기화 작업을 수행할 수 있다.
  • 이번 절은 useState 훅을 소개하면서, 이 훅이 필요한 이유와 사용 방법을 설명한다. 특히 다음과 같은 주제를 살펴볼 것이다.
    • 변수에 값을 대입하는 것만으로 리액트가 작업을 수행할 수 없는 이유
    • useState가 상태 값과 그 값을 갱신하는 함수를 함께 반환하는 방법
    • 상태에 대한 초기값을 값으로 직접 설정하거나 함수로 지연시켜 설정하는 방법
    • 갱신 함수를 사용해 상태를 변경하고 리액트에 변경을 알리는 방법
    • 기존 값을 활용해 갱신 값을 호출해야 할 때, 기존 값의 최신 상태를 사용하는 방법
  • 이 목록은 약간 무섭게 느껴질 수 있지만, useState 훅은 매우 쉽게 사용할 수 있으므로(그리고 아주 자주 이 훅을 사용하게 될 것이다.) 걱정하지 마라. 우리는 그저 모든 기본적인 내용을 다룰 뿐이다. useState를 맨 처음 호출하기 전에, 상태를 우리가 직접 관리하려고 할 때 어떤 일이 생기는지 살펴보자.

2.2.1. 변수에 새로운 값을 대입해도 UI가 바뀌지 않는다.

  • 그림 2.8은 BookableList 컴포넌트에 대한 첫 번째 시도에서 원하는 것을 보여준다. 원하는 것은 4개의 방 중 Lecture Hall을 선택한 목록이다. 

  • BookableList 컴포넌트에서 방 목록을 표시하기 위해서는 데이터에 접근해야 한다. 이 데이터를 static.json 데이터베이스 파일에서 가져온다. 또한 컴포넌트는 현재 선택된 방을 추적해야 한다. 리스트 2.7에서는 bookableIndex를 1로 하드코딩한 코드가 나와 있다.(깃 브랜치가 변경됐으므로 git checkout 0202-hard-coded를 통해 브랜치를 전환하라)
// 리스트 2.7 : 선택한 방이 하드코딩되어 있는 BookablesList 컴포넌트
// \src\components\Bookables\BookablesList.js

import {bookables} from "../../static.json";

export default function BookablesList () {

  const group = "Rooms";

  const bookablesInGroup = bookables.filter(b => b.group === group);

  const bookableIndex = 1;

  return (
    <ul className="bookables items-list-nav">
      {bookablesInGroup.map((b, i) => (
        <li
          key={b.id}
          className={i === bookableIndex ? "selected" : null}
        >
          <button
            className="btn"
          >
            {b.title}
          </button>
        </li>
      ))}
    </ul>
  );
}
  • 코드는 static.json 파일에서 가져온 예약 가능 대상의 배열을 지역 변수인 bookalbles에 대입한다. 이 단계를 다음과 같이 여러 단계로 진행할 수도 있다.
import data from "../../static.json";

const {bookables} = data;
  • 하지만 다른 곳에 data가 필요하지는 않으므로 bookables에 직접 대입했다.
import {bookables} from "../../static.json";
  • 이렇게 구조분해(destructuring)를 사용하는 접근 방식을 이 책에서 자주 사용할 것이다. 예약 가능 자원이었으면, 지정된 그룹에 속하는 자원만 걸러내 가져와야 한다.
  const group = "Rooms";

  const bookablesInGroup = bookables.filter(b => b.group === group);
  • filter 메서드는 새 배열을 반환하고, 이 배열을 bookablesInGroup 변수에 대입한다. 그 후 bookablesInGroup에 대해 map을 사용해 표시할 책 목록을 생성한다.  나는 map 함수 안에서 짧은 변수 이름을 사용했다. b는 bookable을 나타내고 i는 index를 나타낸다. 각 변수가 대입된 위치 근처에서 쓰이기 때문에 이런 짧은 이름도 충분히 이해할 수 있지만, 더 서술적인 이름을 원하는 독자도 있을 수 있다.
  • 이 새 컴포넌트를 표시하려면 BookablesPage 컴포넌트에 새 컴포넌트를 연결해야 한다. 리스트 2.8은 이를 위해 필요한 두 가지 변경을 보여준다.
// 리스트 2.8 : BookablsList를 보여주는 BookablesPage 컴포넌트
// src/components/Bookables/BookablesPage.js

// 새 컴포넌트를 임포트
import BookablesList from "./BookablesList";

export default function BookablesPage () {
  return (
    <main className="bookables-page">
      // 플레이스홀더 텍스트를 컴포넌트로 변경
      <BookablesList/>
    </main>
  );
}

 

  • BookablesList 안에서 하드코딩한 인덱스 값을 변경해 보라. 컴포넌트는 변경한 인덱스에 맞는 항목을 강조해 표시해 준다. 지금까지는 아주 좋다. 하지만 코드를 변경해 강조된 방을 변경하는 것이 전부다. 우리가 진짜 원하는 일은 사용자가 예약 가능 자원을 클릭해서 선택을 변경하는 것이다. 따라서 리스트 항목 버튼에 이벤트 핸들러를 추가하자. 사용자가 예약 가능한 방을 클릭하면 해당 항목이 선택되고, UI가 그에 맞게 갱신되면서 선택된 항목이 강조돼야 한다. 리스트 2.9는 changeBookable 함수와 이 함수를 호출하는 onClick 이벤트 핸들러를 포함한다.
// 리스트 2.9 : BookablesList 컴포넌트에 이벤트 핸들러 추가하기
// /src/components/Bookables/BookablesList.js

import {bookables} from "../../static.json";

export default function BookablesList () {
  const group = "Rooms";
  const bookablesInGroup = bookables.filter(b => b.group === group);

  // 새 변수를 대입해야 하기 때문에 let 키워드를 사용해 변수를 정의
  let bookableIndex = 1;

  // 클릭된 예약 가능 자원의 인덱스를 bookableIndex 변수에 대입하는
  // 함수를 선언
  function changeBookable (selectedIndex) {
    bookableIndex = selectedIndex;
    console.log(selectedIndex);
  }

  return (
    <ul className="bookables items-list-nav">
      {bookablesInGroup.map((b, i) => (
        <li
          key={b.id}
          className={i === bookableIndex ? "selected" : null}
        >
          <button
            className="btn"
            // 클릭된 예약 가능 자원의 인덱스를 changeBookable 함수에 전달하는
            // onClick 이벤트 핸들러를 포함시킴
            onClick={() => changeBookable(i)}
          >
            {b.title}
          </button>
        </li>
      ))}
    </ul>
  );
}
  • 이제 방 중 하나를 클릭하면 그 방의 인덱스가 bookableIndex 변수에 대입된다. 이제 끝났다! 응? 잠깐..리스트 2.9의 코드를 실행하고 다른 방을 클릭하면 강조된 부분이 변경되지 않음을 확인할 수 있다. 하지만 코드는 분명히 bookableIndex 값을 변경한다! 인덱스가 로그에 표시되는 것을 확인하려면 콘솔을 보라. 그렇다면 새로 선택한 항목이 화면에 표시되지 않는 이유는 무엇일까? 왜 리액트가 UI를 갱신하지 않을까?
  • 괜찮다. 일단 숨을 깊게 들이마시자. 기억하라. 컴포넌트는 UI를 반환하는 함수다. 리액트는 UI에 대한 서술을 얻기 위해 이 함수를 호출한다. 리액트가 함수를 언제 호출하고 UI를 갱신해야 할지 어떻게 알 수 있을까? 컴포넌트 함수 안에서 변수값을 변경해도 리액트가 그 사실을 알 수는 없다. 남들의 주의를 끌고 싶은데 그냥 머릿속에만 "여러분!" 하고 외쳐서는 안된다. 여러분은 컴포넌트 상태가 바뀌었다는 사실을 크게 외쳐야 한다. 그림 2.9는 컴포넌트에서 값을 직접 변경할 때 어떤 일이 일어나는지를 보여준다. 리액트는 상태 변경을 알지 못한다. 그래서 리액트는 휘파람을 부르면서 행복하게 위젯을 멋지게 치장하지만, UI는 결코 변경되지 않는다.

컴포넌트 코드에서 변수를 직접 변경해도 UI가 갱신되지 않는다.

  • 그렇다면 어떻게 리액트의 주의를 끌고 우리가 원하는 일을 하게 할까? useState 훅을 호출하면 된다.

2.2.2. useState를 호출하면 값과 갱신 함수를 돌려받는다

값을 직접 변경하는 대신 , 우리는 갱신 함수를 호출한다. 갱신 함수가 값을 변경하면, 리액트가 컴포넌트를 호출해 UI를 재계산하고 그에 따라 화면을 갱신한다.

  • 컴포넌트 코드 실행이 끝났을 때 상태 값이 사라지는 일을 막기 위해 우리 대신 리액트가 상태 값을 처리하게 해야 한다. useState가 하는 일이 바로 상태 관리다. 리액트가 UI를 얻기 위해 우리 컴포넌트를 호출할 때마다 컴포넌트는 리액트에게 최신 상태 값을 문의 하고 그 상태를 갱신 하기 위한 함수를 요청할 수 있다. 컴포넌트는 UI를 생성할 때 이 값을 사용하고, 값을 바꿀 때는 갱신 함수를 사용한다. 예를 들어, 사용자가 목록의 항목을 클릭하면 갱신 함수를 호출 해 상태를 바꿀 수 있다. 
  • 그림 2.11처럼 useState를 호출하면 원소가 2개인 배열에 값과 그 값을 변경할 때 사용할 갱신 함수가 반환된다.

useState 함수는 상태 값과 갱신 함수를 포함하는 배열을 반환한다.

  • 다음과 같이 반환된 배열을 변수에 대입하고 인덱스를 사용해 두 원소에 접근할 수 있다.
// useState 함수는 배열을 반환
const selectedRoomArray = useState();

// 첫 번째 원소는 값
const selectedRoom = selectedRoomArray[0];

// 두 번째 원소는 값을 갱신하는 함수
const setSelectedRoom = selectedRoomArray[1];
  • 하지만 보통은 배열 구조 분해(array destructuring)를 사용해 반환된 원소를 한 번에 변수에 대입한다.
const [selectedRoom, setSelectedRoom] = useState();
  • 배열 구조 분해 대입은 우리가 선택한 변수에 배열 우너소를 대입할 수 있게 해준다. selectedRoom과 setSelectedRoom이라는 이름을 우리가 원하는 대로 선택할 수 있다. 두 번째 원소인 갱신 함수를 위한 변수 이름은 set으로 시작하는 것이 일반적이지만, 다음과 같이 작성해도 잘 작동한다.
const [myRoom, updateMyRoom] = useState();
  • 변수에 초기값을 설정하려면 useState 함수에 초기값을 인자로 전달하라. 리액트가 처음 컴포넌트를 실행할 때 useState는 다른 경우와 마찬가지로 원소가 2개인 배열을 반환하지만(인자로 전달된) 초기값을 배열의 첫 번째 요소로 대입해준다. 그림 2.12에서 이 상황을 볼 수 있다.

컴포넌트가 처음 실행될 때, 리액트는 useState를 통해 전달받은 초기값을 selected 변수에 대입한다.

  • 컴포넌트 안에서 다음 코드가 처음 실행되면 리액트는 배열이 첫 번째 원소로 Lecture Hall이라는 값을 돌려준다. 이 코드는 그 값을 다시 selected라는 변수에 대입한다.
const [selected, setSelected] = useState("Lecture Hall");
  • BookablesList 컴포넌트가 useState 훅을 사용해서 선택된 항목의 인덱스 값 관리를 리액트에게 맡기도록 변경하자. 우리는 초기값으로 1을 전달한다. 따라서 BookablesList 컴포넌트가 처음 화면에 표시될 때 Lecture Hall이 강조된 것을 볼 수 있어야 한다.

'Lecture Hall'이 선택된 BookablesList 컴포넌트

  • 리스트 2.10은 변경된 컴포넌트 코드를 보여준다. 이 코드에는 사용자가 예약 가능 자원을 클릭하면 setBookableIndex에 대입된 갱신 함수를 사용하는 onClick 이벤트 핸들러가 들어 있다.
// 리스트 2.10 : 선택한 방이 변경될 때 UI를 갱신하도록 하는 방법
// src/components/Bookables/BookablesList.js

import {useState} from 'react';
import {bookables} from "../../static.json";

export default function BookablesList () {
  const group = "Rooms";
  const bookablesInGroup = bookables.filter(b => b.group === group);
  
  // useState를 호출하고 반환된 상태 값과 갱신 함수를 변수에 대입
  const [bookableIndex, setBookableIndex] = useState(1);

  return (
    <ul className="bookables items-list-nav">
      {bookablesInGroup.map((b, i) => (
        <li
          key={b.id}
          // 상태 값을 사용해 UI를 생성
          className={i === bookableIndex ? "selected" : null}
        >
          <button
            className="btn"
            // 상태 값을 변경하기 위해 갱신 함수를 호출
            onClick={() => setBookableIndex(i)}
          >
            {b.title}
          </button>
        </li>
      ))}
    </ul>
  );
}
  • 리액트는 BookablesList 컴포넌트 코드를 실행하고, useState 호출은 bookableIndex에 쓰일 값을 반환한다. 컴포넌트는 이 값을 사용해 각 li 엘리먼트에 대한 올바른 className 애트리뷰트를 설정한 UI를 생성한다. 사용자가 예약 가능한 항목을 클릭하면 onClick 이벤트 핸들러가 갱신 함수 setBookableIndex를 사용해 리액트에게 관리 중인 상태 값을 갱신하도록 알린다. 값이 변경되면 리액트는 새 UI 버전이 필요하다는 사실을 알 수 있다. 리액트는 다시 BookablesList 코드를 실행하면서 새로운 상태 값을 bookableIndex에 대입하고, 컴포넌트가 갱신된 UI를 생성하게 한다. 그 후, 리액트는 새로 생성된 UI를 이전 버전과 비교해 어떻게 효율적인 방법으로 DOM을 갱신할지 결정한다.
  • useState를 사용하면 리액트가 컴포넌트의 말을 듣게 된다. 리액트는 상태와 UI를 동기화하겠다는 약속을 지킨다. BookablesList 컴포넌트는 특정 상태의 UI를 서술하고, 상태를 변경할 수 있는 방법을 사용자에게 제공한다. 리액트는 UI가 이전 버전과 다른지 확인(diffing)하고, 갱신을 일괄 처리하고 스케줄링하며, 효율적인 DOM 갱신 방법을 결정하고, 우리를 대신해 DOM을 처리한다. 우리는 상태에 집중하고, 리액트 UI 차이 검사와 DOM 갱신을 수행한다.
  • 리스트 2.10에서는 useState의 초기값으로 1을 전달했다. 사용자가 다른 예약 가능 자원을 클릭하면 해당 값을 다른 숫자로 치환(replace)한다. 만약 객체처럼 더 복잡한 대상을 상태로 저장하려면 어떻게 해야 할까? 그 경우 상태를 갱신할 때 좀 더 조심해야 한다. 왜 그런지 이유를 살펴보자.

도전과제 2.1

  • 데이터베이스에서 사용자 목록을 보여주는 UsersList 컴포넌트를 만들어라. 사용자를 선택할 수 있게 하고, 이 컴포넌트를 UsersPage에 연결하라(아직 데이터베이스 파일을 복사하지 않았다면 깃허브 리포지터리에서 전체 데이터베이스 파일을 복사할 수 있다는 사실을 기억하라.)
// src/components/Users/UsersList.js

import {useState} from 'react';
import {users} from "../../static.json";

export default function UsersList () {
  const [userIndex, setUserIndex] = useState(0);

  return (
    <ul className="users items-list-nav">
      {users.map((u, i) => (
        <li
          key={u.id}
          className={i === userIndex ? "selected" : null}
        >
          <button
            className="btn"
            onClick={() => setUserIndex(i)}
          >
            {u.name}
          </button>
        </li>
      ))}
    </ul>
  );
}
// src/components/Users/UsersPage.js

import UsersList from "./UsersList";

export default function UsersPage () {
  return (
    <main className="users-page">
      <UsersList/>
    </main>
  );
}

도전과제 2.2

  • UserPicker 드롭다운 리스트 컴포넌트를 변경해서 리스트의 옵션으로 사용자를 표시하라. 이번에는 이벤트 핸들러 연결에 대해 걱정하지 마라.
// src/components/Users/UserPicker.js

import {users} from "../../static.json";

export default function UserPicker () {
  return (
    <select>
      {users.map(u => (
        <option key={u.id}>{u.name}</option>
      ))}
    </select>
  );
}