JAN's History

React 학습하기 - State 관리하기 본문

React

React 학습하기 - State 관리하기

JANNNNNN 2025. 8. 3. 14:12

https://ko.react.dev/learn/managing-state 를 보고 정리한 블로그입니다.

1. State를 통해 Input 다루기

  1. 컴포넌트의 다양한 시각적 State를 확인한다. 
  2. 무엇이 State 변화를 트리거하는지 알아내고 변화의 흐름을 정의한다.
  3. 메모리의 state를 useState로 표현한다.
  4. 불필요한 state변수를 제거한다.
    1. 좋은 state 설계는 가능한 한 state를 단순화(합치기)해서 "불가능한 상태"를 없앤다.
  5. state 설정을 위해 이벤트 핸들러를 연결한다.

즉, 핵심 흐름 상태를 -> 선언하고 -> 이벤트로 상태를 업데이트하면 -> React가 상태에 맞춰 UI를 자동으로 보여준다

2. State 구조 선택하기

State 구조화 원칙

  • 연관된 state 그룹화하기 - 두 개 이상의 state를 동시에 업데이트 한다면, 병합하자
  • state 모순 피하기 - 불일치한 state가 있으면 실수가 생길 수 있으니 피하자
  • 불필요한 state 피하기 - state를 통해 계산할 수 있다면 굳이 새로운 state를 만들지 말자
  • state의 중복 피하기 - 중복된 데이터는 동기화가 어렵다.
  • 깊게 중첩된 state 피하기 - 평탄한 방식으로 구현하자

단일 vs 다중 state 변수를 사용하는 경우

// 연관된 State는 단일 객체로 묶어라
//❌ 나쁜 예시
const [x, setX] = useState(0);
const [y, setY] = useState(0);
 
 
//✅ 좋은 예
const [position, setPosition] = useState({ x: 0, y: 0 });

원칙:

  • x와 y처럼 항상 같이 변경되는 값이면, 객체로 묶자.
  • 따로 관리하면 동기화 실패할 위험이 있다.

주의:

  • 객체로 묶으면, 업데이트할 때 기존 값 복사(...) 해야 한다.
setPosition(prev => ({ ...prev, x: 100 }));

state를 구성할 때 피해야할 사항

// 연관된 State는 단일 객체로 묶어라
//❌ 나쁜 예시
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
 
//문제: isSending과 isSent가 동시에 true가 될 수도 있음 → 버그 위험
 
 
//✅ 좋은 예
const [status, setStatus] = useState('typing'); // 'typing' | 'sending' | 'sent'

요약:

  • "같이 변하는" boolean 여러 개 대신,
  • 하나의 명확한 status 값을 사용하자.

상태 구조의 일반적인 문제를 해결하는 방법

관련된 값이 서로 따로 관리됨 객체로 묶어라
여러 boolean으로 관리 하나의 status로 합쳐라
계산 가능한 값 저장 저장하지 말고 계산해라
중복된 데이터 저장 ID만 저장해라
깊게 중첩된 구조 평탄화해서 관리해라

=> 가능한 한 단순하게, 하지만 필요 이상으로 단순하게는 하지말자

3. 컴포넌트 간 State 공유하기

State 끌어올리기를 통해 컴포넌트 간 state를 공유하는 방법

  • 여러 컴포넌트가 같은 정보를 써야 할 때공통 부모 컴포넌트로 state를 옮긴다.
  • 자식 컴포넌트들은 props로 값을 받고, props로 받은 핸들러를 통해 부모의 state를 바꾼다.

=> 상태를 부모한테 맡기고, 자식은 props만 받아서 행동한다.

제어 컴포넌트와 비제어 컴포넌트

상태 부모가 가진다 (props) 자기 안에서 관리 (useState)
제어 부모가 완전 제어 자기 마음대로
언제 폼 입력, 동기화 필요할 때 간단한 값만 필요할 때

4. State를 보존하고 초기화하기

React가 언제 state를 보존하고 언제 초기화하는지?

  • 같은 위치 같은 컴포넌트일 경우 state가 유지된다.
  • 같은 위치 다른 컴포넌트일 경우 state가 초기화된다.
  • 컴포넌트를 DOM에서 제거하면 state도 삭제된다. 

=> 위치(location) + 컴포넌트(type) 둘 다 같아야 state를 유지한다

어떻게 React가 컴포넌트의 state를 초기화하도록 강제할 수 있는지?

  • 다른 위치에 렌더링 (if/else로 구분)
  • key를 부여해서 강제로 새로운 컴포넌트로 인식시키기
{isPlayerA ? <Counter key="A" /> : <Counter key="B" />}

=> key를 다르게 주면 무조건 다른 컴포넌트로 취급해 state를 초기화한다.

key와 타입이 state 보존에 어떻게 영향을 주는지?

  • 컴포넌트 타입이 다르면 state를 무조건 초기화한다.
  • 같은 타입이지만 key가 다르면 state를 초기화한다 (다른 컴포넌트로 인식하기 때문)
  • 같은 타입, 같은 key (or key없음) 이면 state를 보존한다.

=> type와 key 모두 중요, 하나라도 다르면 초기화된다.

5. State 로직을 reducer로 작성하기

reducer 함수란 무엇인가

  • reducer 함수 = (현재 state, action) → 새로운 state 를 반환하는 순수 함수.
  • 즉, 지금 state와 "어떤 일이 벌어졌는지(action)"를 받아서, "그 결과로 어떻게 변할지를" 계산하는 함수

useState에서 useReducer로 리팩토링 하는 방법

// useState일 때
setTasks([...tasks, newTask]);
// useReducer로 바꾸면
dispatch({ type: 'added', task: newTask });
  • setState 대신 dispatch로 바꾼다
    • 직접 state를 바꾸지 말고, dispatch(action) 을 호출해서 "무슨 일이 일어났는지"만 설명.
  • reducer 함수를 따로 만든다.
    •  action을 보고 "다음 state가 뭐가 되어야 하는지"를 계산.
  • useReducer(reducer, 초기값) 로 연결
    • useState 대신 useReducer를 쓰고, [state, dispatch] 구조분해할당.

reducer를 언제 사용할 수 있는지

  • 여러 state 업데이트가 복잡하게 얽혀있을 때 (예: 여러 input 조작, 복잡한 form 처리)
  • 이벤트 핸들러마다 setState가 많아져서 컴포넌트가 복잡해질 때
  • 상태 변화 패턴이 명확할 때 (예: '추가', '수정', '삭제' 등)
  • 디버깅, 테스트를 쉽게 하고 싶을 때 (reducer 함수만 따로 테스트 가능)
  • 관심사를 분리하고 싶을 때 (컴포넌트 → UI / reducer → 로직)

reducer를 잘 작성하는 방법

  • 순수 함수로 만들기.
    • 입력값(state, action)이 같으면 항상 결과(newState)도 같아야 한다.
  • switch-case를 사용해 깔끔하게 분기하기.
  • action.type은 의미 있는 이름으로!
  • 각 action은 "한 가지 사용자 행동" 을 나타내야 한다.
    • 예시: ADD_TASK, TOGGLE_TASK, RESET_FORM
  • 필요하면 Immer를 사용해서 불변성을 쉽게 관리할 수 있다

=> 복잡한 state 관리는 useReducer로 통합해서, 더 읽기 쉽고 디버깅하기 쉬운 코드로 만들자

6. Contect를 사용해 데이터를 깊게 전달하기

”Prop drilling” 이란?

  • 데이터를 사용하려는 컴포넌트까지 props를 중간중간 계속 전달하는 것.
  • 불필요한 중간 컴포넌트들도 props를 받아야 해서 코드가 지저분해지고 관리가 어려워짐.

Context로 반복적인 prop 전달 대체하기

  • Context를 쓰면 중간 단계를 생략하고 필요한 컴포넌트가 바로 데이터를 가져올 수 있다.
  • 작업 순서
    1. createContext() 로 context 객체 만들기
    2. useContext() 로 자식 컴포넌트에서 읽기
    3. <Context.Provider value={값}> 로 상위 컴포넌트가 값 제공

Context의 일반적인 사용 사례

  • UI 테마: 다크모드, 폰트 스타일 등 전체 앱에 적용되는 시각적 설정
  • 현재 로그인 사용자: 어떤 컴포넌트에서도 현재 사용자 정보를 접근해야 할 때
  • 라우팅 정보: 현재 URL 경로나 활성 링크 관리
  • 글로벌 상태 관리: 복잡한 앱에서는 전역적으로 공유되는 상태를 다룰 때

Context의 일반적인 대안

  • 그냥 props로 넘기기
    =>  중간에 몇 번만 전달하는 거라면 props 전달이 더 깔끔하고 명확하다.
  • 컴포넌트 구조 개선
    => 중간 컴포넌트가 필요 없이 children 패턴(컴포넌트 합성)으로 구조를 단순화할 수 있다.

7. Reducer와 Context로 앱 확장하기

reducer와 context를 결합하는 방법

<TasksContext.Provider value={tasks}>
  <TasksDispatchContext.Provider value={dispatch}>
    {children}
  </TasksDispatchContext.Provider>
</TasksContext.Provider>
  • useReducer로 state와 dispatch를 만든다.
  • Context 2개 생성:
    • TasksContext: state(tasks) 저장
    • TasksDispatchContext: dispatch 함수 저장
  • Provider를 이용해 트리 아래 모든 컴포넌트에 state와 dispatch를 제공한다.

state와 dispatch 함수를 prop으로 전달하지 않는 방법

const tasks = useContext(TasksContext);
const dispatch = useContext(TasksDispatchContext);
  • props로 tasks, dispatch 를 일일이 넘기지 않고
  • 필요한 컴포넌트 안에서 useContext 로 직접 가져온다

context와 state 로직을 별도의 파일에서 관리하는 방법

TasksContext.js 파일에 다 모아 관리:

export function useTasks() {
  return useContext(TasksContext);
}
export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}
  • TasksContext와 TasksDispatchContext 생성
  • TasksProvider 컴포넌트 만들어서 context 제공
  • useTasks, useTasksDispatch 같은 커스텀 Hook도 같이 정의