Published on

리액트 공식문서 스터디 6-1 주차

Authors
  • avatar
    Name
    junyeol kim

시작하며...

  • 각 컴포넌트는 독립된 State를 가진다.
  • React는 UI 트리에서의 위치를 통해 각 State가 어떤 컴포넌트에 속하는지 추적한다.
  • 리렌더링마다 언제 State를 보존하고 초기화할지 컨트롤할 수 있다.

State는 어떻게 렌더트리의 위치에 연결될까?

  • React는 UI 안의 컴포넌트 구조를 기반으로 렌더 트리를 만든다.

  • 컴포넌트 안에서 useState를 호출하면, state 값은 컴포넌트 내부가 아니라 React 내부의 state 저장소에 따로 저장된다.

  • 컴포넌트는 어떤 컴포넌트의 State인지에 대한 Key값만 갖게된다.

  • React는 렌더 시 컴포넌트와 hook의 호출 순서를 기준으로 각 state를 저장소의 올바른 슬롯에 연결한다. 즉, “렌더 트리의 위치 + hook 호출 순서”로 state를 매칭하는 구조이다.

그렇기에 hook은 항상 같은 순서로 호출되어야하는 규칙이 있는 것이다!

그렇다면 컴포넌트가 언마운트되면 State는 어떻게 제거가 될까?

  • React에는 Fiber Node라는 구조가 컴포넌트마다 존재한다.

  • 각 Fiber Node가 그 컴포넌트의 state 저장 슬롯 목록을 갖고 있다.

  • 언마운트시 Fiber Node가 제거되면서, 연결되어있던 State 슬롯도 같이 제거된다.

같은 위치의 동일한 컴포넌트는 State를 보존한다

  • React는 컴포넌트를 렌더 트리의 특정 위치(Fiber Node)로 관리한다.
  • 같은 자리에 같은 컴포넌트가 다시 렌더되면, React는 기존 Fiber Node를 재사용한다.
  • Fiber Node가 유지되는 경우, 그 Node에 연결된 state 저장 슬롯도 그대로 보존된다.
  • 따라서 화면에서 사라진 것처럼 보여도 “언마운트되지 않았다면” State는 유지된다.

같은 위치의 다른 컴포넌트는 State를 초기화한다

  • React는 컴포넌트의 타입(예: A vs B)에 따라 서로 다른 Fiber Node를 생성한다.
  • 같은 자리에 다른 컴포넌트가 오면, 기존 Fiber Node는 폐기되고 새로운 Node가 생성된다.
  • 새로운 Fiber Node에는 기존 State 슬롯이 없기 때문에 모든 State는 초기값으로 다시 생성된다.
  • 즉, Fiber Node가 변경되면 State도 완전히 초기화된다.

같은 위치에서 State를 초기화하는 방법

import { useState } from 'react'

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true)
  return (
    <div>
      {isPlayerA ? <Counter person="Taylor" /> : <Counter person="Sarah" />}
      <button
        onClick={() => {
          setIsPlayerA(!isPlayerA)
        }}
      >
        Next player!
      </button>
    </div>
  )
}
// Next player! 이라는 버튼을 클릭하면, 플레이어가 바뀌지만 점수는 유지되는 코드 일부

1) 다른 위치에서 컴포넌트를 렌더링한다.

import { useState } from 'react'

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true)
  return (
    <div>
      {isPlayerA && <Counter person="Taylor" />}
      {!isPlayerA && <Counter person="Sarah" />}
      <button
        onClick={() => {
          setIsPlayerA(!isPlayerA)
        }}
      >
        Next player!
      </button>
    </div>
  )
}
// 적은 수의 독립된 컴포넌트만 가지고 있을 때 편한 방법
// Next player! 버튼을 누를때마다 DOM에서 컴포넌트가 제거되어 State가 초기화된다.

2) key를 이용해 State를 초기화한다.

import { useState } from 'react'

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true)
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}
      <button
        onClick={() => {
          setIsPlayerA(!isPlayerA)
        }}
      >
        Next player!
      </button>
    </div>
  )
}
// 배열을 렌더링할때와 동일하게 key값을 다르게 지정해 state의 공유를 막을 수 있다.

key가 전역적으로 유일하지 않다는 것을 기억해야 합니다. key는 오직 부모 안에서만 자리를 명시합니다.

2-1)key를 이용해 form을 초기화하기

import { useState } from 'react'
import Chat from './Chat.js'
import ContactList from './ContactList.js'

export default function Messenger() {
  const [to, setTo] = useState(contacts[0])
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={(contact) => setTo(contact)}
      />
      <Chat contact={to} />
      {/* <Chat id={to.id} contact={to} */}
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' },
]

// contacts의 id값 속성을 이용하여 state를 초기화하여 메세지 입력칸을 비운다.

제거된 컴포넌트의 State를 보존하기

  • 컴포넌트가 언마운트되면 해당 Fiber Node가 제거되며 연결된 State도 함께 사라진다.
  • 하지만 UX 요구에 따라 “보이지 않는 컴포넌트의 State를 유지해야 하는” 상황도 존재한다.
  • 이러한 경우 아래와 같은 몇 가지 접근을 사용할 수 있다.

1) 컴포넌트를 언마운트하지 않고 숨기기

  • 특정 컴포넌트를 조건부 렌더링으로 제거하는 대신, 항상 렌더링시키고 CSS로만 감춘다.
  • 렌더 트리에서 사라지지 않기 때문에 Fiber Node가 유지되고 기존 State도 그대로 보존된다.
  • 단, 숨겨진 내용이 많아 DOM이 커지면 성능 문제가 발생할 수 있다.

2) State를 상위 컴포넌트로 끌어올리기 (Lifting State Up)

  • 자식 컴포넌트를 제거해도 데이터가 유지되어야 한다면 부모 컴포넌트가 State를 관리하게 한다.
  • 자식은 props를 통해 State를 렌더링할 뿐이므로 언마운트되더라도 데이터는 남아 있게 된다.
  • 여러 조건부 UI에서 가장 보편적으로 사용되는 방식이다.

3) React 외부 저장소 사용하기

  • 사용자가 페이지를 닫아도 데이터를 유지하고 싶다면 localStorage 같은 외부 저장소를 활용할 수 있다.
  • 저장된 값을 기반으로 Chat(혹은 특정 UI)을 다시 초기화할 수 있다.

State 로직을 reducer로 작성하기

reducer를 사용하여 state 로직 통합하기

export default function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  function handleAddTask(text) {
    setTasks([...tasks, {
      id: nextId++,
      text: text,
      done: false
    }]);
  }

  function handleChangeTask(task) {
    setTasks(tasks.map(t => {
      if (t.id === task.id) {
        return task;
      } else {
        return t;
      }
    }));
  }

  function handleDeleteTask(taskId) {
    setTasks(
      tasks.filter(t => t.id !== taskId)
    );
  }
  ...
  • 각 이벤트 핸들러는 state를 업데이트하기 위해 setTasks를 호출한다. 컴포넌트가 커질수록 state를 관리하는 로직 또한 방대해지기 때문에, 복잡성을 줄이고 접근성을 높이기 위해서 컴포넌트 내부의 state 로직을 컴포넌트 외부의 reducer라고 하는 단일 함수로 옮길 수 있다.

  • useState -> useReducer로 바꾸는 방법

    • state 값을 설정하는 대신 action을 전달하기.
    • reducer 함수 작성하기.
    • 컴포넌트에서 reducer 사용하기.
1) state 값을 설정하는 대신 action(dispatch)을 전달하기
function handleAddTask(text) {
  dispatch({
    type: 'added',
    id: nextId++,
    text: text,
  })
}

function handleChangeTask(task) {
  dispatch({
    type: 'changed',
    task: task,
  })
}

function handleDeleteTask(taskId) {
  dispatch({
    type: 'deleted',
    id: taskId,
  })
}
  • reducer를 사용한 state 관리는 setState로 직접 값을 설정하는 방식과 접근 방식이 다르다.

  • setState는 “state를 이렇게 바꿔라”라고 결과를 직접 명령하는 방식이다.

  • 반면 reducer는 이벤트 핸들러에서 action을 전달해 “사용자가 방금 한 일”을 설명한다.

  • 실제 state 변경 로직은 reducer 내부에서 처리되므로, 이벤트 핸들러는 의도만 전달한다.

2) reducer 함수 작성하기
  • reducer 함수는 state를 어떻게 업데이트할지에 대한 로직을 담는 순수 함수이다.

  • 두 개의 인자를 받는다:

    1. 현재 state 값
    2. action 객체
  • reducer는 action을 기반으로 계산된 다음 state를 반환한다.
    (React는 reducer가 반환한 값을 새로운 state로 설정한다.)

export function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ]
    }
    case 'changed': {
      return tasks.map((t) => (t.id === action.task.id ? action.task : t))
    }
    case 'deleted': {
      return tasks.filter((t) => t.id !== action.id)
    }
    default: {
      throw Error('Unknown action: ' + action.type)
    }
  }
}
3) 컴포넌트에서 reducer 사용하기
  • reducer 함수는 useReducer 훅과 함께 사용된다.

  • useReducer는 두 값을 반환한다:

    1. 현재 state

    2. dispatch 함수

  • dispatch는 action 객체를 reducer로 전달하는 역할을 한다.

import tasksReducer from ...

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId
    });
  }

  return (
    <div>
      {/* UI 코드 */}
    </div>
  );
}

useState와 useReducer 비교하기

비교 항목useStateuseReducer
코드 크기간단한 state일 때 코드가 가장 짧다.reducer 함수 + action dispatch 작성으로 초기 코드량이 많아진다.
하지만 비슷한 형태의 업데이트가 반복되면 더 짧아질 수 있다.
가독성간단한 state에는 가독성이 좋다.
복잡해질수록 컴포넌트 코드가 길어지기 쉽다.
복잡한 state 구조에서 업데이트 로직을 명확히 분리할 수 있어 가독성이 좋아진다.
디버깅state가 어디서 잘못 설정됐는지 찾기 어려울 때가 있다.action 흐름이 명확해 디버깅이 쉽다.
하지만 reducer 내부를 단계적으로 확인해야 하는 경우도 있다.
테스팅컴포넌트 중심이라 테스트는 UI 단위가 많다.reducer는 순수 함수 → 독립적으로 테스트하기 쉽다.
사용 적합한 상황단순한 state / 특정 값만 변경하는 경우state 구조가 복잡한 경우
여러 이벤트가 같은 상태를 다양한 방식으로 변경하는 경우
state 업데이트 로직을 하나로 모으고 싶을 때
개인 취향간결함을 선호하면 useState구조화된 패턴을 선호하면 useReducer
혼합 사용 여부둘은 동일한 원리로 동작하며, 같은 컴포넌트 안에서도 함께 사용할 수 있다.

reducer를 잘 작성하는 방법

  • Reducer는 비동기 로직이나 side-effect를 포함할 수 없으며, 동일한 입력에 대해 동일한 출력을 반환하는 순수 함수여야 한다.
    • state 업데이트 함수와 비슷하게 렌더링때 실행되므로 입력값이 같다면 실행값이 동일해야한다.

  • 각 action은 데이터 안에서 여러 변경사항이 있더라도 하나의 사용자 상호작용을 설명해야한다.
    • 여러 필드를 초기화하는 상황에서 필드마다 action을 전송하기 보다는 전체 필드에 대한 action을 전송하는 것이 효율적이다.

Immer로 간결한 reducer 작성하는 방법

Immer는 모든 reducer를 간결하게 만들지는 않는다. 특정 패턴에서는 큰 이점을 제공하지만, 단순한 업데이트에서는 변화가 거의 없다.

상황Immer 효과설명예시
배열에 push / splice / 직접 변경 필요효과 매우 큼기존 reducer에서는 새 배열 생성이 필요하지만, Immer에서는 draft를 직접 수정할 수 있어 코드가 매우 간결해진다.draft.push(newTask)
draft.splice(index, 1)
깊게 중첩된 객체 업데이트효과 매우 큼불변성 때문에 기존 코드가 길어지지만 Immer에서는 중첩된 경로를 직접 수정할 수 있다.

기존: { ...state, user: { ...state.user, ... } }
Immer: draft.user.settings.darkMode = true;

배열 수정(find 후 필드 변경)효과 큼특정 항목만 변경할 때 직접 수정 가능해 코드가 더 직관적이다.const t = draft.find(...); t.done = true;
filter / map 같은 불변 메서드 사용효과 거의 없음원래부터 불변 업데이트가 쉬운 API라 Immer를 써도 코드 길이가 거의 동일하다.return tasks.filter(...)
return draft.filter(...)
얕은 state 한두 개만 업데이트효과 미미state 구조가 얕아 Immer의 이점이 크게 드러나지 않는다.

기존: return { ...state, open: !state.open }
Immer: draft.open = !draft.open

단순한 값 변경 (boolean, number 등)효과 미미어차피 객체/배열 복사가 필요 없고 코드가 짧기 때문에 Immer 장점이 거의 없다.

return count + 1
(Immer 필요 없음)