React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 6. 컴포넌트 상태

favor 2026. 4. 3. 02:36

상태가 필요한 이유

React 컴포넌트에서 데이터를 정의하는 기본적인 방법 : let, const 키워드 활용

변수에 저장된 값이 변경되더라도 화면에는 반영되지 않는다는 단점 

 

React는 상태 기반의 UI 프레임워크

--> 특정 값이 변경되었을 때 화면을 자동으로 업데이트 하려면 '상태'라고 불리는 개념으로 관리 필요

 

상태를 관리하는 방법

  • useState hook
  • useReducer hook

 

useState - 1

상태(state) : 시간이 지남에 따라 변할 수 있는 데이터

 

리액트 훅(hook) : 함수형 컴포넌트에서 상태 관리와 생명 주기 기능 및 부가적인 기능을 활용할 수 있게 도와주는 새로운 기능 

 

useState 훅의 기본적인 문법 형태

const [state, setState] = useState<Type>(initialState)

  • state
    • 실제 상태값이 할당될 변수
    • == 상태 변수
    • 값이 변경되면 화면을 다시 그리는 리렌더링을 통해 즉시 반영
  • setState
    • 상태값을 변경할 때 사용하는 함수
    • == 상태 업데이트 함수 
  •  Type
    • 상태값의 타입
    • 생략하는 경우가 많지만, 초기값과 변경될 데이터가 다를 경우에는 명시해주어야 함 
  •  initialState
    • 상태의 초기값

 

useState 훅은 React 패키지에서 import 해야 사용 가능

 

useState는 두 개의 값을 반환

  1. 실제 할당될 상태값
  2. 해당 상태값을 업데이트할 수 있는 함수

 

// App.tsx

import { useState } from "react";

export default function App() {
  const [state, setState] = useState<number>(0);

  const handleSetToTen = () => {
    setState(10);
  }

  return (
    <>
      <h1>state: {state}</h1>
      <button onClick={handleSetToTen}>Set To 10</button>
    </>
  );
}

 

useState - 2

초기 상태값과 변경된 후의 상태값의 타입이 다를 경우에는 제네릭 타입 생략 불가능 

// App.tsx

import { useState } from "react";

export default function App() {
  const [state, setState] = useState<number | string>(0); // 초기에는 number, 나중엔 string

  const handleSetToTen = () => {
    setState("10"); // string 타입
  }

  return (
    <>
      <h1>state: {state}</h1>
      <button onClick={handleSetToTen}>Set To 10</button>
    </>
  );
}

 

상태 업데이트 방법

상태 변수에 할당된 값은 반드시 상태 업데이트 함수를 통해서만 변경

--> React는 상태값이 변경 것을 추적

--> 변경된 상태를 기반으로 리렌더링

 

렌더링 원리

  • 배치 업데이트
    • 성능 최적화를 위해, 여러 개의 상태 업데이트를 하나의 리렌더링으로 묶어서 처리하는 방식
  • 클로저와 스냅샷
    • React가 렌더링을 할 때, 그 시점의 상태 변수 값은 초기값으로 고정

 

상태 업데이트 방식

  • 직접 업데이트 방식
    • 상태 업데이트 함수에 변경할 값을 직접 넣어주는 방식
    • setState(value);
    • ex) setCount(count+1); 
  • 함수형 업데이트 방식 
    • 상태 업데이트 함수의 인수로 함수를 작성하는 방식
    • setState((state) => state);
    • ex) setCount((prev) => prev+1);

 

직접 업데이트 방식 

// App.tsx

import { useState } from "react";

export default function App() {

  const [count, setCount] = useState(0);
  const handleIncrement = () => {
    setCount(count + 1); // 0 + 1
    setCount(count + 1); // 0 + 1
    setCount(count + 1); // 0 + 1
  }

  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={handleIncrement}>increment</button>
    </>
  );
}

^ 버튼을 클릭할 때마다 count가 1씩 증가 (O) / 3씩 증가 (X) 

ㄴ 이러한 결과가 나타나는 이유는 배치 업데이트클로저 두 가지 원인이 복합적으로 얽혀있기 때문

 

배치 업데이트 - setCount()를 세 번 호출했지만, 한 번 호출될 때마다 한 번의 리렌더링을 발생시키는 것이 아님

클로저 - count의 초기값이 0이기 때문에 실질적으로 각각의 setCount(count + 1)이 (0 + 1)을 의미 

 

함수형 업데이트 방식 

// App.tsx

import { useState } from "react";

export default function App() {

  const [count, setCount] = useState(0);
  const handleIncrement = () => {
    setCount((count) => count + 1); // 0 + 1
    setCount((count) => count + 1); // 1 + 1
    setCount((count) => count + 1); // 2 + 1
  }

  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={handleIncrement}>increment</button>
    </>
  );
}

^ 버튼을 클릭할 때마다 count가 3씩 증가 (O) / 1씩 증가 (X)

 

함수형 업데이트 방식에서는 항상 함수의 매개변수에 최신의 상태값이 들어옴 

ㄴ 배치 업데이트에 의해서 한 번의 렌더링에 모든 상태 변경을 몰아서 처리한다고 하더라도, 클로저에 구애받지 않음

 

상태 업데이트 방법 복습하기

상태 업데이트 방식 선택 기준 : 변경될 값이 현재 상태 값을 참조하느냐, 참조하지 않느냐로 결정

 

객체 상태 정의하기

객체 데이터로 정의된 상태값 중 일부만 업데이트하고 싶을 때에는 스프레드 연산자 활용

// App.tsx

import { useState } from "react";

export default function App() {
  const [userInfo, setUserInfo] = useState({
    name: "jack",
    age: 20,
    gender: "male",
  });

  const handleUpdateUserInfo = () => {
    setUserInfo({
      ...userInfo, // 스프레드 연산자
      name: "mike" // 바꿀 값으로 덮어씌우기 
    });

  };

  return (
    <>
      <p>name: {userInfo.name}</p>
      <p>age: {userInfo.age}</p>
      <p>gender: {userInfo.gender}</p>

      <button onClick={handleUpdateUserInfo}>Update User Info</button>
    </>
  );
}

 

객체 상태 정의하기 심화

여러 개의 객체가 중첩되어 있는 경우에도, 스프레드 연산을 활용해 기존 객체를 전개하고 덮어 씌우는 방식으로 코드를 작성 

// App.tsx

import { useState } from "react";

export default function App() {
  const [userInfo, setUserInfo] = useState({
    name: "jack",
    age: 20,
    gender: "male",
    contact: {
      email: "jack@example.com",
      phone: "123-456-7890",
    },
    address: {
      home: {
        street: "123 Main St",
        city: "New York",
        zipCode: "10001",
      },
      office: {
        street: "456 Business Ave",
        city: "New York",
        zipCode: "10002",
      },
    },
  });

  const handleUpdateUserInfo = () => {
    setUserInfo({
      ...userInfo,
      name: "mike",
      age: 30,
      contact: {
        ...userInfo.contact,
        email: "react@naver.com"
      },
      address: {
        home: {
          ...userInfo.address.home,
          street: "321 Main st",
        },
        office: {
          ...userInfo.address.office,
          street: "134 Ave",
        }
      }
    });
  };

  return (
    <>
      <pre>{JSON.stringify(userInfo, null, 2)}</pre>
      <button onClick={handleUpdateUserInfo}>UpdateUserInfo</button>
    </>
  );
}

 

배열 상태 다루기

배열 요소 추가하기

// App.tsx

import { useState } from "react";

export default function App() {

  const [fruits, setFruits] = useState(['apple', 'banana', 'orange']);
  const handAddFruit = () => {
    setFruits((fruits) => [...fruits, "melon"]); // 함수형 업데이트 방식 
  };

  return (
    <>
      <p>{fruits.join(", ")}</p>
      <button onClick={handAddFruit}>Add Fruit</button>
    </>
  );
}

 

배열 요소 변경하기 

// App.tsx

import { useState } from "react";

export default function App() {

  const [fruits, setFruits] = useState(['apple', 'banana', 'orange']);
  const handAddFruit = () => {
    setFruits((fruits) => fruits.map(fruit => fruit === 'apple' ? 'grape' : fruit)); // 함수형 업데이트 방식 
  };

  return (
    <>
      <p>{fruits.join(", ")}</p>
      <button onClick={handAddFruit}>Add Fruit</button>
    </>
  );
}

 

배열 요소 사이에 새로운 요소 추가하기 

// App.tsx

import { useState } from "react";

export default function App() {

  const [fruits, setFruits] = useState(['apple', 'banana', 'orange']);
  const handAddFruit = () => {
    setFruits((fruits) => [...fruits.slice(0, 1), 'grape', ...fruits.slice(1)]); // 함수형 업데이트 방식 
  };

  return (
    <>
      <p>{fruits.join(", ")}</p>
      <button onClick={handAddFruit}>Add Fruit</button>
    </>
  );
}

 

상태 독립성 이해하기

useState로 생성한 '상태'는 각각의 컴포넌트에서 독립적

// App.tsx

import FirstCount from "./components/FirstCount";
import SecondCount from "./components/SecondCount";

export default function App() {
  return (
    <>
      <FirstCount />
      <SecondCount />
    </>
  );
}
// FirstCount.tsx

import { useState } from "react";

export default function FirstCount () {

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

  return (
    <>
      <h1>FirstCount Component: {count}</h1>
      <button onClick={() => setCount((count) => count+1)}>increment</button>
    </>
  );
}
// SecondCount.tsx

import { useState } from "react";

export default function SecondCount () {

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

  return (
    <>
      <h1>SecondCount Component: {count}</h1>
      <button onClick={() => setCount((count) => count+1)}>increment</button>
    </>
  );
}

 

* 컴포넌트 내부에서 정의되어져 있는 상태값은 해당 컴포넌트 내부에서만 유효

 

상태 끌어올리기

독립적으로 상태가 변경되고 있는 두 컴포넌트에서, 상태를 하나로 통합하여 공유하는 방법

: 두 컴포넌트의 공통적인 부모 또는 조상 컴포넌트에서 상태를 정의하고 해당 상태와 상태 업데이트 함수를 props로 전달 

 

// App.tsx

import { useState } from "react";
import FirstCount from "./components/FirstCount";
import SecondCount from "./components/SecondCount";

export default function App() {

  // 부모 컴포넌트 내부에서 정의
  const [count, setCount] = useState(0);

  return (
    <>
      {/* props로 전달 */}
      <FirstCount count={count} setCount={setCount} />
      <SecondCount count={count} setCount={setCount} />
    </>
  );
}
// FirstCount.tsx

import { Dispatch, SetStateAction } from "react";

export default function FirstCount({
  count,
  setCount,
}: {
  count: number;
  setCount: Dispatch<SetStateAction<number>>;
}) {
  return (
    <>
      <h1>FirstCount Component: {count}</h1>
      <button onClick={() => setCount((count) => count + 1)}>increment</button>
    </>
  );
}
// SecondCount.tsx

import { Dispatch, SetStateAction } from "react";

export default function SecondCount({
  count,
  setCount,
}: {
  count: number;
  setCount: Dispatch<SetStateAction<number>>;
}) {

  return (
    <>
      <h1>SecondCount Component: {count}</h1>
      <button onClick={() => setCount((count) => count + 1)}>increment</button>
    </>
  );
}

^ 여러 자식 컴포넌트가 동일한 상태를 공유하고, 일관된 방식으로 상태를 업데이트하는 것이 가능 

 

캡슐화 이해하기

캡슐화 : 특정 함수 내부에 상태 업데이트 로직을 구현하는 것

 

상태 업데이트 함수를 직접 넘겨주는 것보다는, 캡슐화한(상태 업데이트 로직을 구현한) 함수를 넘겨주는 것을 권장 

 

// App.tsx

import { useState } from "react";
import FirstCount from "./components/FirstCount";
import SecondCount from "./components/SecondCount";

export default function App() {

  const [count, setCount] = useState(0);
  
  // 캡슐화 - 미리 선언 
  const handleIncrement = () => {
    setCount((count) => count + 1);
  }

  return (
    <>
      {/* props로 전달 */}
      <FirstCount count={count} handleIncrement={handleIncrement} />
      <SecondCount count={count} handleIncrement={handleIncrement} />
    </>
  );
}
// FirstCount.tsx

export default function FirstCount({
  count,
  handleIncrement,
}: {
  count: number;
  handleIncrement: () => void;
}) {
  return (
    <>
      <h1>FirstCount Component: {count}</h1>
      <button onClick={handleIncrement}>increment</button>
    </>
  );
}
// SecondCount.tsx

export default function SecondCount({
  count,
  handleIncrement,
}: {
  count: number;
  handleIncrement: () => void;
}) {
  return (
    <>
      <h1>SecondCount Component: {count}</h1>
      <button onClick={handleIncrement}>increment</button>
    </>
  );
}

 

리액트 훅 공통 규칙

  1. 리액트 훅은 use로 시작해야 함
    • 리액트에서 사용하는 모든 훅은 use로 시작
      • useState도 리액트 훅의 한 종류이기 때문에 use로 시작 
  2. 훅은 최상위에서만 호출되어야 함
    • 훅은 컴포넌트 함수의 최상위에서만 호출
    • 조건문이나 반복문 내에서 호출 불가능 

 

useReducer - 1

useReducer : 컴포넌트를 정의하는 또다른 방법

ㄴ useState 훅에 비해 복잡한 상태 관리를 하는 데 효율적

 

useReducer 훅의 기본적인 문법 형태

const [state, dispatch] = useReducer<Type>(reducer, initialState)

  • state
    • 실제 상태값이 할당될 변수
    • == 상태 변수
  • dispatch
    • reducer 함수를 호출해서 새로운 상태값 설정
    • == 액션 발생 함수
  • Type
    • 상태값의 타입
    • 보통 초기값의 타입을 적어줌
  • reducer
    • 리듀서 함수 
    • useReducer에서 현재 상태 값을 결정하는 역할 
  • initialState
    • 상태의 초기값

 

useReducer - 2

reducer 함수 구조

function reducer(state, action) { ... }

  • state : 현재의 상태값
  • action : 어떻게 바꿀지에 대한 정보가 담긴 객체
    • type, payload 두 가지 정보를 담고 있음
    • type : 어떤 행동을 할지 나타내는 '이름표' (주로 대문자로 작성)
  • 리턴값 : 새로운 상태값을 계산해서 리턴 

 

reducer 함수는 reducer 폴더 아래에 별도의 파일(.ts)로 빼기도 함 

 

// App.tsx

import { useReducer } from "react";
import counterReducer from "./reducer/counterReducer";

export default function App() {

  // const [state, dispatch] = useReducer(reducer, 0);
  
  const [count, countDispatch] = useReducer(counterReducer, 0);
  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={() => countDispatch({ type: "DECREMENT" })}>감소</button>
      <button onClick={() => countDispatch({ type: "RESET" })}>리셋</button>
      <button onClick={() => countDispatch({ type: "INCREMENT" })}>증가</button>
    </>
  );
}
// counterReducer.ts

export default function counterReducer(
  count: number,
  action: { type: string } // type 속성이 string인 객체
) {
  switch (action.type) {
    case "INCREMENT":
      return count + 1;
    case "DECREMENT":
      return count - 1;
    case "RESET":
      return 0;
    default:
      return count;
  }
}