Skip to content

[2주차] useReducer #2

@yuiseo

Description

@yuiseo

해당 문서는 노션에 정리했던 것을 옮긴 것으로, 약간의 깨짐이 발생할 수 있어 아래 링크에서 확인 가능.
https://oesiu24.notion.site/useReducer-2c9ec73d8f6a809481b0e1a6034fc70e?source=copy_link

State 로직을 reducer로 작성

💡

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

한 컴포넌트에서 state 업데이트가 여러 이벤트 핸들러로 분산 되는 경우, state를 업데이트 하는 모든 로직을 reducer를 사용해 컴포넌트 외부의 단일 함수로 통합해 관리

useReducer(reducer, initialArg, init?)

매개변수

  • reducer: state가 어떻게 업데이트 되는지 지정하는 Reducer 함수
    • Reducer 함수는 반드시 순수 함수여야 하며, State와 Action을 인수로 받아야 하고, 다음 State를 반환해야 한다.
    • State와 Action에는 모든 데이터 타입이 할당될 수 있다.
  • initialArg: 초기 State가 계산되는 값
    • 모든 데이터 타입이 할당될 수 있다.
    • 초기 State가 어떻게 계산되는지는 다음 init 인수에 따라 달라진다.
  • 선택사항 init: 초기 State를 반환하는 초기화 함수
    • 이 함수가 인수에 할당되지 않으면 초기 State는 initialArg로 설정된다.
    • 할당되었다면 초기 State는 init(initialArg)를 호출한 결과가 할당된다.

반환값

  • 현재 state : 첫번째 렌더링에서의 state는 init(initialArg) 또는 initialArg로 설정 (init이 없을 경우 initialArg로 설정됩니다).
  • **dispatch 함수 :** dispatch는 state를 새로운 값으로 업데이트하고 리렌더링을 일으킴.

주의사항

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

https://ko.react.dev/learn/extracting-state-logic-into-a-reducer#consolidate-state-logic-with-a-reducer

예시 코드에서는 state가 배열을 보유하며 세가지 이벤트 핸들러를 통해 state를 업데이트 하고 있다.

따라서, 컴포넌트가 커질수록 그 안에서 state를 다루는 로직의 양이 늘어나게 된다. 복잡성은 줄이고 접근성을 높이기 위해 컴포넌트 내부의 state 로직을 컴포넌트 외부의 reducer라고 불리는 단일 함수로 옮긴다.

useState를 useReducer로 바꾸기

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

1단계.state를 설정하는 것에서 action을 dispatch 함수로 전달하는 것으로 바꾸기

직접 state로 관리

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를 설정해 무엇을 할 것인지 명시중이다.

먼저, state 관련 로직을 지워 세가지 이벤트의 핸들러만 남기면 다음과 같다.

  • 사용자가 “Add”를 눌렀을 때 호출되는 handleAddTask(text).
  • 사용자가 task를 토글하거나 “Save”를 누르면 호출되는 handleChangeTask(task).
  • 사용자가 “Delete”를 누르면 호출되는 handleDeleteTask(taskId).
🥊

Reducer의 state 관리 vs 직접 state 설정

  • 직접 state 설정
    • 무엇을 할 지 지시
  • reducer의 state 관리
    • 이벤트 핸들러에서 action을 전달해 사용자가 방금 한 일을 지정
    • state 업데이트 로직은 다른 곳에 있음
    • 이벤트 핸들러를 통해 task를 설정하는 대신 task를 추가/변경/삭제하는 action을 전달

직접 state로 관리

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)
  );
}

Reducer의 state 관리

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
  });
}
  • dispatch에 넣어준 객체를 action이라고 한다.
  • 이 안에 어떤 것이든 자유롭게 넣을 수 있지만, 일반적으로 어떤 상황이 발생하는지에 대한 최소한의 정보를 담고 있어야 한다.

action 객체는 어떤 형태든 될 수 있다.

그렇지만 발생한 일을 설명하는 문자열 type 을 넘겨주고 이외의 정보는 다른 필드에 담아서 전달하도록 하는 것이 일반적. type은 컴포넌트에 따라 값이 다르다. type에는 무슨 일이 일어나는지를 설명할 수 있는 이름을 넣어주면 됨.

dispatch({
  // 컴포넌트마다 다른 값
  type: 'what_happened',
  // 다른 필드는 이곳에
});

2단계: reducer 함수 작성하기

reducer 함수는 state에 대한 로직을 넣는 곳.

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

function yourReducer(state, action) {
  // React가 설정하게될 다음 state 값을 반환합니다.
}

react는 reducer에서 반환한 값을 state에 설정한다.

이 예시에서 이벤트 핸들러에 구현 되어있는 state 설정과 관련 로직을 reducer 함수로 옮기기 위해서 다음과 같이 하면 된다.

  1. 첫 번째 인자에 현재 state (tasks) 선언하기.
  2. 두 번째 인자에 action 객체 선언하기.
  3. reducer에서 다음 state 반환하기 (React가 state에 설정하게 될 값).
function tasksReducer(tasks, action) {
  if (action.type === 'added') {
    return [...tasks, {
      id: action.id,
      text: action.text,
      done: false
    }];
  } else if (action.type === 'changed') {
    return tasks.map(t => {
      if (t.id === action.task.id) {
        return action.task;
      } else {
        return t;
      }
    });
  } else if (action.type === 'deleted') {
    return tasks.filter(t => t.id !== action.id);
  } else {
    throw Error('Unknown action: ' + action.type);
  }
}

개인적으론 switch 문으로 쓰는게 가독성이 더 좋은거 같다..

reducer 함수는 state(tasks)를 인자로 받고 있기 때문에, 이를 컴포넌트 외부에서 선언할 수 있다.

위 코드에서 if/else 문을 사용하고 있지만 reducer 함수 안에서는 switch 문을 사용하는 게 규칙이다. 물론 결과는 같지만, switch 문으로 작성하는 것이 한눈에 읽기 더 쉬울 수 있다. 이제부터 이 문서에서 다룰 예시는 아래처럼 switch 문을 사용하자.

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 => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
  • case 속에서 선언된 변수들이 서로 충돌하지 않도록 case 블록을 중괄호인 {}로 감싸는 걸 추천
  • case는 일반적인 경우라면 return으로 끝나야한다. return을 잊게 되면 코드가 다음 case로 떨어질 수 있다.

3단계: 컴포넌트에서 reducer 사용하기

마지막으로 컴포넌트에 tasksReducer를 연결할 차례이다.

  1. React에서 useReducer hook을 불러온다.

    import { useReducer } from 'react';
  2. useState를 아래 처럼 useReducer로 바꾼다.

    // const [tasks, setTasks] = useState(initialTasks);
    const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
    • useReducer hook은 초기 state 값을 입력받아 유상태(stateful) 값을 반환한다는 점과 state를 설정하는 함수 (useReducer 에서는 dispatch 함수)의 원리를 보면 useState와 비슷하다. 그러나 다음과 같은 점은 다르다.
      • useReducer hook은 두 개의 인자를 넘겨받는다.
        1. reducer 함수
        2. 초기 state 값
      • 그리고 아래와 같이 반환한다.
        1. state를 담을 수 있는 값
        2. dispatch 함수 (사용자의 action을 reducer 함수에게 “전달하게 될”)

완성된 코드

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

  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
    });
  }
  
	return( ... 중략 )
  
  }

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 => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch a puppet show', done: false },
  { id: 2, text: 'Lennon Wall pic', done: false }
];

useStateuseReducer 비교

reducer가 좋은 점만 있는 것은 아니다!!

1. 코드 크기

  • useState
    • 초기 코드가 짧고 간단함
    • 단순한 상태 관리에 적합
  • useReducer
    • reducer와 action 정의가 필요해 초기 코드가 많음
    • 여러 이벤트에서 비슷한 상태 업데이트가 반복되면 오히려 코드가 정리됨

2. 가독성

  • useState
    • 단순한 상태 변경은 직관적이고 읽기 쉬움
    • 상태 로직이 복잡해지면 컴포넌트가 비대해질 수 있음
  • useReducer
    • “무슨 일이 발생했는지(action)”와 “상태가 어떻게 바뀌는지(reducer)”가 분리됨
    • 복잡한 상태 로직에서 구조적으로 더 명확함

3. 디버깅

  • useState
    • 상태 변경 지점이 흩어져 있어 원인 추적이 어려울 수 있음
  • useReducer
    • reducer에 로그를 추가해 어떤 action으로 상태가 바뀌었는지 추적 가능
    • 대신 디버깅 단계와 코드 양은 더 많아질 수 있음

4. 테스팅

  • useState
    • 컴포넌트 단위 테스트에 의존
  • useReducer
    • reducer는 순수 함수이므로 독립적으로 테스트 가능
    • 복잡한 상태 전이 로직 검증에 유리

5. 개인적 취향

  • 어느 쪽이 “더 낫다”기보다는 상황과 선호의 문제
  • 두 Hook은 동일한 React 상태 관리 메커니즘 위에 있음
  • 같은 컴포넌트 안에서도 혼용 가능

결론

  • 단순한 상태 → useState
  • 복잡한 상태, 잦은 버그, 구조화 필요 → useReducer
  • 모든 곳에 reducer를 쓸 필요는 없으며, 필요한 곳에만 선택적으로 적용하는 것이 이상적임

Reducer 잘 작성하기

  1. Reducer는 반드시 순수해야한다.

    • React에서 reducer는 렌더링 과정 중에 실행

    • 즉, 화면을 그리기 위해 “다음 state가 무엇인지 계산”하는 역할

      • 같은 state + action항상 같은 결과
      • 외부 세계에 영향 ❌
      • 내부 데이터 직접 변경 ❌
    • 하면 안되는 것들

      렌더링을 여러 번 호출할 수도 있고, 중간에 취소했다가 다시 실행할 수도 있기 때문

      // ❌ reducer 안에서 절대 금지
      fetch('/api/data');        // 요청 보내기
      setTimeout(() => {}, 1000); // 타이머 등록
      localStorage.setItem(...); // 외부 상태 변경
      state.count++;             // 기존 객체 직접 수정
  2. 각 action은 데이터 안에서 여러 변경들이 있더라도 하나의 사용자 상호작용을 설명해야 한다.

    • action은 ‘코드 이벤트’가 아니라 ‘사용자의 행동’을 표현한다

      action은 단순히 “state를 이렇게 바꿔라”가 아니라 **“사용자가 무엇을 했다”**를 설명

    • ❌ 나쁜 예 (기계적인 action)

      dispatch({ type: 'set_name', value: '' });
      dispatch({ type: 'set_email', value: '' });
      dispatch({ type: 'set_age', value: 0 });
      dispatch({ type: 'set_address', value: '' });
      dispatch({ type: 'set_phone', value: '' });

      겉보기엔 문제 없어 보이지만:

      • “왜 이 action들이 연속으로 발생했는지” 알기 어렵고
      • 로그를 봐도 의도를 파악하기 힘듦
    • ✅ 좋은 예 (의미 있는 action)

      dispatch({ type: 'reset_form' });
      • 사용자가 **“폼을 초기화했다”**는 의미가 명확
      • reducer 안에서 여러 필드를 한 번에 초기화
      • 로그만 봐도 흐름이 바로 이해됨

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

과제

https://ko.react.dev/learn/extracting-state-logic-into-a-reducer#writing-reducers-well 4번

  • 나의 풀이

    import { useState } from 'react';
    
    export function useReducer(reducer, initialState) {
      const [state, setState] = useState(initialState);
    
      // ???
      const dispatch = (action) =>{
        const newState = reducer(state, action)
        setState(newState)
      }
    
      return [state, dispatch];
    }

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions