sohyeon kim

[React] 리액트 공식문서 정리 3(3) : State 관리하기 - Reducer, Context, 리듀서와 컨텍스트로 상태 관리 본문

React

[React] 리액트 공식문서 정리 3(3) : State 관리하기 - Reducer, Context, 리듀서와 컨텍스트로 상태 관리

aotoyae 2024. 12. 6. 14:21
728x90

 

 

💡 리액트 공식문서 정리 : State 관리하기 - State 로직을 리듀서로 작성하기, Context 를 사용해 데이터를 깊게 전달하기, Reducer 와 Context 로 앱 확장하기

 

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

여러 이벤트 핸들러를 통해 많은 state 업데이트가 이루어지는 컴포넌트는 관리하기 어려울 수 있다.

이를 위해 컴포넌트 외부에서 'reducer'라는 단일 함수를 사용해 모든 state 업데이트 로직을 통합할 수 있다.

이벤트 핸들러는 오로지 사용자의 'action'만을 명시하므로 간결해진다.

 

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

아래의 TaskApp 컴포넌트는 state 에 tasks 배열을 보유하고 있고, 세 가지 이벤트 핸들러를 통해 task 를 추가, 제거, 수정한다.

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 를 업데이트하기 위해 setState 를 호출한다.

컴포넌트가 커질수록 state 를 다루는 로직의 양도 늘어나게 되는데, 복잡성은 줄이고 접근성을 높이기 위해

컴포넌트 내부의 state 로직을 외부의 reducer(단일 함수) 로 옮겨보자.

  1. state 를 설정하는 것에서 action 을 dispatch 함수로 전달하는 것으로 변경
  2. reducer 함수 작성
  3. 컴포넌트에서 reducer 사용

1️⃣ state 를 설정하는 것에서 action 을 dispatch 함수로 전달하는 것으로 변경 : 사용자의 의도를 더 명확히 설명

위 예시의 이벤트 핸들러는 state 를 설정함으로써 무엇을 할 것인지 지시하고 있다.

이벤트 핸들러 내 state 설정 로직을 전부 지우고, 'action'을 전달해 '사용자가 방금 한 일'을 지정한다.

즉, 'task 를 설정' 하는 대신 'task 를 추가/변경/삭제'하는 action 을 전달하는 것!

 

👀 action 객체엔 어떤 것이든 넣을 수 있지만, 일반적으로 어떤 상황이 발생했는지에 대한 최소한의 정보를 담아야 한다.

function handleAddTask(text) {
  dispatch({
  // --- action 객체 ---
    type: 'added', // 발생한 일만 설명하는 문자열, 이외 정보는 아래처럼 다른 필드에
    id: nextId++,
    text: text,
  // ------------------
  });
}

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

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

 

2️⃣ reducer 함수 작성하기

위에서 지운 state 에 대한 로직은 이 reducer 함수에 작성한다.

이 함수는 현재 state 값과 action 객체, 이렇게 두 인자를 받아 다음 state 값을 반환한다.

function tasksReducer(tasks, action) { // 현재 state 인 tasks, action 객체
  switch (action.type) {
    case 'added': { // 각자 다른 case 내에 선언된 변수들이 서로 충돌하지 않도록 중괄호로 감싸는 걸 추천
      return [...tasks, { // React가 설정하게될 다음 state 값 반환
        id: action.id,
        text: action.text,
        done: false
      }];
    }
    case 'changed': {
      return tasks.map(t => {
        if (t.id === action.task.id) {
          return action.task; // React가 설정하게될 다음 state 값 반환
        } else {
          return t; 
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id); // React가 설정하게될 다음 state 값 반환
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }

 

3️⃣ 컴포넌트에서 reducer 사용하기

마지막으로 컴포넌트에 tasksReducer 를 연결한다. 

import { useReducer } from 'react';

//...
// const [tasks, setTasks] = useState(initialTasks); 기존에 사용한 useState 는 제거
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

 

useReducer hook 은 두 개의 인자를 받는다.

  1. reducer 함수
  2. 초기 state 값

그리고 아래와 같이 반환한다.

  1. state 를 담을 수 잇는 값
  2. dispatch 함수(사용자의 action 을 reducer 함수에게 '전달하게 될')

useState VS useReducer : reducer 가 좋은 점만 있는 것은 아니다. 둘을 비교해보자.

  • 코드 크기 : 일반적으로 useState 를 사용하면 미리 작성해야 하는 코드가 줄어들지만, useReducer 를 사용하면 reducer 함수와 action 을 전달하는 부분 둘 다 작성해야 한다. 하지만 여러 이벤트 핸들러에서 비슷한 방식으로 state 를 업데이트하는 경우라면, useReducer 를 사용해 코드 양을 줄일 수 있다.
  • 가독성 : useState 로 간단한 state 를 업데이트하는 경우 가독성은 좋은편. 하지만 복잡한 구조의 state 를 다루게 되면 코드 양이 많아져 읽기 어려워질 수 있다. 이 경우 useReducer 로 업데이트 로직이 어떻게 동작하는지, 이벤트 핸들러를 통해 무엇이 발생했는지 구현한 부분을 명확히 구분할 수 있다.
  • 디버깅 : useState 사용 중 버그를 발견했을 때, 왜, 어디서 state 가 잘못 설정됐는지 찾기 어려울 수 있다. useReducer 를 사용하면 콘솔 로그를 reducer 에 추가해 어떤 action 에 의한 버그인지 확인할 수 있다. 각 action 이 올바르다면 버그가 reducer 로직 자체에 있다는 것도 알 수 있다. 하지만 useState 보다 더 많은 코드로 단계별 디버깅을 해야하는 점도 있긴 하다.
  • 테스팅 : reducer 는 컴포넌트에 의존하지 않는 순수 함수. 이는 reducer 를 독립적으로 분리해 내보내거나 테스트할 수 있다는 것을 의미한다. 복잡한 state 업데이트 로직의 경우, reducer 가 특정 초기 state 및 action 에 대해 특정 state 를 반환한다고 생각하고 테스트하는 것이 유용할 수 있다.

➡️ 컴포넌트에서 잘못된 방식의 state 업데이트로 버그가 자주 발생하거나, 해당 코드에 더 많은 구조를 도입하고 싶다면 reducer 사용을 권장한다. 이때 모든 부분에 reducer 를 적용하지 않아도 된다. useState 와 useReducer 를 마음대로 섞고 매치해보자.

 

reducer 잘 작성하기

  • reducer 는 반드시 순수해야 한다. state 업데이트 함수와 비슷하게 reducer 는 렌더링 중 실행된다. (action 은 다음 렌더링까지 대기) 이것은 reducer 는 반드시 순수해야 한다는 걸 의미. 즉, 입력 값이 같아면 결과 값도 항상 같아야 한다. 요청을 보내거나 사이드 이펙트(컴포넌트 외부에 영향을 미치는 작업)를 수행해선 안된다. reducer 는 객체와 배열을 변경하지 않고 업데이트해야 한다.
  • 각 action 은 데이터 안에 여러 변경들이 있더라도 하나의 사용자 상호작용을 설명해야 한다. 예시로, 사용자가 reducer 가 관리하는 5개의 필드가 있는 양식에서 '재설정'을 누른 경우, 5개의 개별 set_field action 보다는 하나의 reset_form action 을 전송하는 것이 더 합리적이다. 모든 action 을 reducer 에 기록하면 어떤 상호작용이나 응답이 어떤 순서로 일어났는지 재구성할 수 있을 만큼 로그가 명확해야 한다. 디버깅에 도움이 되므로

Immer 로 간결하게 reducer 를 작성할 수 있다.

 

❗️ action 객체의 type 은 '사용자가 무엇을 했는지'를 설명한다.

 

위 예시에선 채팅 text 를 변경하면 수정된 메세지와 함께 'edited_message' type 을 전송하고,

Send 버튼을 클릭하면 채팅을 초기화하기 위해 빈 메세지와 'edited_message' type 을 전송하고 있다.

 

이는 잘 동작하는 코드이지만, 사용자의 관점에서 보았을 때 input 필드에 텍스를 작성하는 것과 메세지를 전송하는 것은 다른 행위이다.

이를 반영하기 위해 send_message 라는 새로운 action 을 만들어 reducer 에서 별도로 작성해보자.

export function messengerReducer(
  state,
  action
) {
  switch (action.type) {
  //..
  case 'edited_message': {
      return {
        ...state,
        message: action.message
      };
    }
    case 'sent_message': {
      return {
        ...state,
        message: ''
      };
    }

 

결과적으로 두 방법은 동일하게 동작하지만,

action 객체의 type 은 'state 가 어떻게 변경되길 원하는지' 보다, '사용자가 무엇을 했는지' 를 설명해야 한다는 점을 기억하자.

 

또한 두 방법 모두 reducer 안에 alert 를 작성하지 않는 것이 중요하다. reducer 는 순수함수이어야 하므로, 이 안에선 오직 다음 state 값을 계산하기 위한 작업만 해야 한다. 이런 부분은 이벤트 핸들러 안에서 수행해야 한다. (이 같은 실수를 방지하기 위해 React 는 Strict 모드에서 reducer 를 여러 번 호출한다.)

 

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

props 가 중간에 많은 컴포넌트를 거쳐야(지나쳐야) 하거나, 그 많은 컴포넌트에서 동일한 정보가 필용한 경우, props 를 전달하는 것이 번거롭고 불편할 수 있다. ➡️ props drilling

Context 는 부모 컴포넌트가 그 아래의 트리 전체에(깊이에 상관없이) 데이터를 전달할 수 있도록 해준다.

 

데이터를 '순간이동' 시켜보자!

다음 예시는 level 을 받아 텍스트의 크기 조정을 하고 있다.

 

같은 section 내 여러 제목이 항상 동일한 크기를 가져야 한다고 가정해보자.

 

지금은 각 <Heading> 에 level prop 을 전달하고 있다.

<Section> 컴포넌트에 level 을 전달하고 <Heading> 이 가장 가까운 section 의 레벨을 알아차리게 할 수 있을까?

Context 를 활용하자!

1️⃣ Context 생성하기 : LevelContext 라고 이름을 지어보자.

// LevelContext.js
import { createContext } from 'react';

export const LevelContext = createContext(1);
// 인자는 기본값. 여기서 1은 가장 큰 제목 레벨을 나타내지만 모든 종류의 값을(객체까지) 전달할 수 있다.

 

2️⃣ Context 사용하기 : Heading 에선 LevelContext 를 사용한다.

// Heading.js
import { useContext } from 'react'; // Hook 과
import { LevelContext } from './LevelContext.js'; // 생성한 Context 불러오기

//export default function Heading({ level, children }) {
// ...
//}

export default function Heading({ children }) {
  const level = useContext(LevelContext); // level prop 을 제거하고 LevelContext 를 읽는다.
  // ...
}

 

3️⃣ Context 제공하기 : Section 에선 LevelCOntext 를 제공한다.

LevelContext 를 자식들에게 제공하기 위해 provider 로 감싸준다.

import { LevelContext } from './LevelContext.js';

export default function Section({ level, children }) {
  return (
    <section className="section">
      <LevelContext.Provider value={level}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}

 

이제 React 는 Section 내 컴포넌트가 LevelContext 를 요구하면 Level 을 전해주고,

컴포넌트는 그 위에 있는 UI 트리에서 가장 가까운 <LevelContext.Provider> 의 값을 사용한다.

 

➕ 컴포넌트 내에서 context 를 이용해 전달하기 : 주변에 적응하는 컴포넌트 작성하기

지금은 각 Section 에 level 을 수동으로 지정하고 있다. 하지만 Context 를 통해 상위 컴포넌트에서 정보를 읽어올 수 있으므로 각 Section 은 그 위의 Section 에서  level 을 읽고 자동으로 level + 1 을 전달할 수 있다.

export default function Section({ children }) {
  const level = useContext(LevelContext);
  return (
    <section className="section">
      <LevelContext.Provider value={level + 1}>
        {children}
      </LevelContext.Provider>
    </section>
  );
}

 

이렇게 바꾸면 Section 과 Heading 둘 모두에 level 을 전달할 필요가 없다.

 

🙋‍♂️ 이 예시에선 하위 컴포넌트가 context 를 오버라이드 할 수 있는 방법을 시각적으로 보여주기에 제목 레벨을 사용한다.

하지만 context 는 다른 상황에서도 유용하다. 현재 색상 테마, 현재 로그인된 사용자 등 전체 하위 트리에 필요한 정보를 전달할 수 있다.

  • 테마 지정 : 사용자가 모양을 변경할 수 있는 앱의 경우(e.g. 다크 모드), provider 를 앱 최상단에 두고 시각적으로 조정이 필요한 컴포넌트에서 context 를 사용할 수 있다.
  • 현재 계정 : 로그인한 사용자를 알아야하는 컴포넌트에서 활용. 일부 앱에선 동시에 여러 계정을 운영할 수도 있다.(e.g. 다른 사용자로 댓글을 남기는 경우) 이런 경우 UI 의 일부를 서로 다른 현재 계정 값을 가진 provider 로 감싸주는 것이 편리하다.
  • 라우팅 : 대부분의 라우팅 솔루션은 현재 경로를 유지하기 위해 내부적으로 conntext 를 사용한다. 이것이 모든 링크의 활성화 여부를 '알 수 있는' 방법.
  • 상태 관리 : 앱이 커지면 결국 앱 상단에 수많은 state 가 생기게 된다. 아래 멀리 떨어진 많은 컴포넌트가 그 값을 변경하고 싶을 수 있다. 흔히 reducer 를 context 와 함께 사용하는 것은 복잡한 state 를 관리하고 번거로운 작업 없이 멀리 있는 컴포넌트까지 값을 전달하는 방법이다. 

Context 는 정적인 값으로 제한되지 않는다. 다음 렌더링 시 다른 값을 준다면 React 는 아래의 모든 컴포넌트에서 값을 갱신한다.

이것이 context 와 state 가 자주 조합되는 이유!

일반적으로 트리의 다른 부분에서 멀리 떨어져 있는 컴포넌트들이 같은 정보가 필요하다는 것은 context 를 사용하기 좋다는 징조이다.

 

 

Context 의 작동 방식은 CSS 속성 상속을 연상시킨다.

CSS 에서 <div> 에 대해 color: blue 를 지정하고, 중간에 있는 다른 DOM 노드가 green 으로 재정의되지 않는 한 그 안의 모든 노드는 그 색상을 상속한다. 마찬가지로, React 에서 위에서 가져온 어떤 context 를 재정의하는 유일한 방법은 자식들을 다른 값을 가진 context provider 로 래핑하는 것!

 

CSS 에서 color 와 background-color 같이 다른 속성들은 서로 영향을 주지 않는 것처럼 서로 다른 React context 는 영향을 주지 않는다. createContext() 로 만든 각각의 context 는 완벽히 분리되어 있고 특정 context 를 사용 및 제공하는 컴포넌트끼리 묶여 있다. 하나의 컴포넌트에서 문제 없이 서로 다른 context 를 사용하거나 제공할 수 있다.

 

❗️ Context 를 사용하기 전에 고려할 것

Context 는 남용하기 쉬운데, 어떤 props 를 여러 레벨 깊이로 전달해야 한다 해서 바로 context 에 넣어야 하는 것은 아니다.

  • 먼저 Props 전달하기로 시작하자. 사소한 컴포넌트들이 아니라면 여러 props 가 여러 컴포넌트를 지나가는 것은 이상한 일이 아니다. 힘든 일처럼 느껴질 수 있지만 어떤 컴포넌트가 어떤 데이터를 사용하는지 매우 명확히 해준다. 데이터의 흐름이 props 를 통해 분명해져 코드 유지보수에도 좋다.
  • 컴포넌트를 추출하고 JSX 를 children 으로 전달하기. 데이터를 사용하지 않는 중간 컴포넌트 층을 통해 어던 데이터를 전달하는(더 아래로 보내기만 하는) 경우엔 컴포넌트 추출을 잊은 경우가 많다. 예를 들어 posts 처럼 직접 사용하지 않는 props 를 <Layout posts={posts} /> 처럼. 대신 Layout 은 children 을 prop 으로 받고, <Layout><Posts posts={posts} /></Layout> 을 렌더링하자. 이렇게 하면 데이터를 지정하는 컴포넌트와 데이터가 필요한 컴포넌트 사이의 층수가 줄어든다.

 

7. Reducer 와 Context 로 앱 확장하기

컴포넌트의 state 업데이트 로직을 통합해주는 Reducer, 멀리 떨어져있는 컴포넌트에 정보를 전달해주는 Context,

이 둘을 함께 사용해 복잡한 화면의 state 를 관리해보자.

 

Reducer 와 context 결합하기

 

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

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

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

// ...
}

 

Reducer 는 이벤트 핸들러를 짧고 간결하게 유지하는 데 도움이 된다. 그러나 앱이 커지면 다른 어려움에 부딪힐 수 있다.

현재 tasks state 및 dispatch 함수는 최상위 TaskApp 컴포넌트에서만 사용 가능하다.

다른 컴포넌트가 작업 목록을 읽거나 변경하려면 현재 state 와 이벤트 핸들러를 props 로 전달해야 한다.

 

간단한 예시에선 잘 동작하지만, 수십 수백개의 컴포넌트를 거쳐 state 나 함수를 전달하긴 쉽지 않다.

그러니 tasks state 와 dispatch 함수를 context 에 넣어 관리하자!

➡️ 이제 트리에서 TaskApp 아래 모든 컴포넌트가 props drilling 없이 tasks 와 dispatch actions 를 읽을 수 있게 된다.

 

1️⃣ Context 생성

트리를 통해 전달하려면, 두 개의 별개 context 를 생성해야 한다.

import { createContext } from 'react';

export const TasksContext = createContext(null); // 현재 tasks 리스트 제공
export const TasksDispatchContext = createContext(null); // 컴포넌트에서 action 을 dispatch 하는 함수 제공
// 두 context 에 모두 기본값으로 null 을 전달하고 있는데, 실제 값은 TaskApp 에서 제공될 것.

 

2️⃣ State 와 dispatch 함수를 context 에 넣기

이제 TaskApp 컴포넌트에서 두 context 를 모두 가져올 수 있다.

useReducer() 에서 반환된 tasks 및 dispatch 를 가져와 트리 전체에 제공하자.

 

3️⃣ 트리 안에서 context 제공하기 : prop 을 통한 전달 제거

이제 tasks 리스트나 이벤트 핸들러를 트리 아래로 전달하지 않아도 된다.

대신 필요한 컴포넌트에서는 TaskContext 에서 tasks 를 읽을 수 있고, 리스트를 업데이트 하기 위해 context 의 dispatch 함수를 읽고 호출할 수 있다.

 

TaskApp, TaskList, Task 컴포넌트는 어떤 이벤트 핸들러도 아래로 전달하지 않으며, 각 컴포넌트는 필요한 context 를 읽는다.

State 는 여전히 최상위 TaskApp 컴포넌트에서 useReducer 로 관리되고 있다.

그러나 이제 context 를 가져와 트리 아래 모든 컴포넌트에서 해당 tasks 및 dispatch 를 사용할 수 있다.

 

하나의 파일로 합치기

Reducer 와 context 를 모두 하나의 파일에 작성해 컴포넌트들을 조금 더 정리할 수 있다.

새 컴포넌트인 TasksProver 에 기존에 선언했던 두 context 와 Reducer 를 작성한다. 이 컴포넌트는 모든 것을 하나로 묶는 역할을 하게 된다.

  • Reducer 로 state 를 관리
  • 두 context 를 모두 하위 컴포넌트에 제공
  • children 을 prop 으로 받기에 JSX 전달 가능

 

이렇게 하면 TaskApp 컴포넌트의 복잡성과 연결이 모두 제거된다.

TasksContext.js 에서 context 를 사용하기 위한 use 함수들도 내보낼 수 있고, 이 함수를 통해 컴포넌트에서 context 를 읽을 수 있다.

export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

const tasks = useTasks();
const dispatch = useTasksDispatch();

 

동작이 바뀌는 건 아니지만, 다음에 context 를 더 분리하거나 함수들에 로직을 추가하기 쉬워졌다.

이제 모든 context 와 reducer 는 TasksContext.js 에 있다. 컴포넌트들이 데이터를 어디서 가져오는지가 아닌 무엇을 보여줄 것인지에 집중할 수 있도록 깨끗이 정리되었다.

 

 

 

🔗 https://ko.react.dev/learn/extracting-state-logic-into-a-reducer

 

state 로직을 reducer로 작성하기 – React

The library for web and native user interfaces

ko.react.dev

 

 

 

728x90
반응형