(번역) Reducer에 State 로직 추출하기 - NEW 리액트 공식문서

hongregii·2023년 3월 12일
0

너무 많은 이벤트 핸들러들과 너무 많은 State updater 함수가 있는 컴포넌트는 복잡해지기 쉽상이다. 이럴 때 모든 state upater 로직을 컴포넌트 밖에 있는 reducer라는 단 하나의 함수에 때려박을 수 있다!

이 문서에서는

  • reducer 함수란 무엇인지
  • useStateuseReducer로 리팩토링하는 법
  • reducer는 언제 쓸지
  • reducer 잘 쓰기
    를 배워봅시다.

Reducer에 state 로직을 통합하자!

컴포넌트가 더 복잡해질 수록, 그 컴포넌트의 state가 어떻게 업데이트되는지 한번에 보기가 어려워진다. 예를 들어, 아래의 TaskApp 컴포넌트는 tasks라는 state를 가지고 있고, 이벤트 핸들러 3개를 사용해서 task들을 추가, 제거, 수정한다.

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

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

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

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},
];

각 이벤트 핸들러는 setTasks 를 불러서 state를 업데이트한다. 이 컴포넌트가 커지면, state 로직도 막 퍼질 것이다. 이 복잡도를 줄이고 로직을 한 곳에 몰아넣어서 접근을 조금 편하게 하고 싶다! 그렇다면 이 state 로직들을 컴포넌트 밖의 reducer라는 함수에 빼놓으면 된다.

Reducer는 state를 관리하는 또 다른 방식이다. 아래 3단계만 따라하면 useState에서 useReducer로 이사갈 수 있다.

  1. set statedispatch actions
  2. reducer 함수 작성
  3. reducer 사용

1단계 : set State → dispatch actions

위 코드에서 이벤트 핸들러들을 살펴보자.
setState를 사용해서 무엇을 할지 를 정해준다.

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

setState들을 다 없애자! 그럼 남은 건 호출밖에 없다.. :

  • handleAddTask(text) : 사용자가 "Add" 누르면 호출
  • handleChangeTask(task) : 사용자가 task를 토글하거나 "Save" 누르면 호출
  • handleDeleteTask(taskId) : 사용자가 "Delete" 누르면 호출

state를 reducer로 관리하는 것은 직접 setState를 하는 것과는 조금 다르다. 리액트한테 "이것을 해줘"라고 직접 말하는 게 아니고, 이벤트 핸들러로부터 "actions"를 보내줘서 (=dispatch) "사용자가 방금 무엇을 했는지"를 지정하는 것이다.

state 업데이트 로직은 다른 곳에 살고 있을 것임!
그니깐 이벤트 핸들러를 통해 tasks state를 설정 (=setState) 하는 것이 아니라, "task를 추가/수정/삭제 했음!" 이라는 액션을 보내주는(=dispatch) 것이다! 이게 보다 더 사용자의 의도를 잘 설명해준다.
아래는 setStatedispatch 로 바꾼 코드

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

그러니까 dispatch 에게 넘겨주는 인자는 "action" 객체임.

function handleDeleteTask(taskId) {
  dispatch(
    // 바로 요놈 : "action" object:
    {
      type: 'deleted',
      id: taskId,
    }
  );
}

걍 JS 객체다. 무엇을 넣든 상관 없지만, 보통은 방금 무엇이 일어났는지 에 대한 최소한의 정보를 포함해야 함. (다음 문서에서 dispatch 함수 그 자체를 추가해보자)

action 객체

는 아무 shape 여도 상관 없다.

컨벤션 :

  • 보통 type 에 무슨 일인지 설명하는 string 부여.
  • 다른 필드들에 모든 추가 정보 넣음
  • type는 컴포넌트마다 다를 수 있다. 이 예시에서는 added, added_task 모두 ㄱㅊ.
    이름으로 무슨 일이 일어났는지를 잘 골라보자!
dispatch({
  // specific to component
  type : '무슨_일인고',
  // 다른건 다 이 밑에...

2단계 : reducer 함수 작성!

reducer 함수에 state 로직을 넣는다.

  • 두 가지 인자를 받는다 : 현재 state, action 객체.
  • return 값은 바뀐 state.
  • 리액트는 return 보고 state를 설정한다.
function yourReducer(state, action) {
  return 이렇게 바꿔줘잉 state
}

이 예시에서는 이렇게!

  1. 첫번째 인자로 현재 state (tasks) 선언
  2. 두번째 인자로 action 객체 선언
  3. reducer의 return 부분에 다음 state 넣기

예시의 reducer 함수는 이렇다 :

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 문을 사용하면 더 깔끔해진다.

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 문에 return 넣는 것을 잊지 마라! 안쓰면 다음 case로 빠질 수 있다.

reducer 함수는 컴포넌트 밖에 선언하자.

state를 인자로 받기 때문에, 굳이 state가 살고 있는 컴포넌트 안에서 선언될 필요가 없음. 가독성을 위해 따로 파일을 빼서 선언하자.

왜 reducer라고 부름 ?

사실 이거 엄청 궁금했던건데 쌔로운 문서 최고얌

  • 코드 양을 줄여주기도 하지만, "reduce", 사실은 arrayreduce() 메서드에서 따온 것.

    reduce() ?

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
  (result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

reduce() 는 array를 받아서 여러 값 중 하나의 값을 "쌓아서" 돌려준다.

reduce() 메서드가 받는 인자를 바로 reducer 함수라고 부름. 여태까지의 결과값현재 값 을 받아서 다음 값 을 돌려준다.
리액트의 reducer 도 같은 논리 : 여태까지의 state 값action 을 받아서 다음 state 를 돌려줌. 이러면 state를 state로 다시 축적할 수 있다!

실제로 array.reduce() 메서드를 사용하여 reducer를 거의 그대로 구현할 수 있다고 한다. 여기서는 생략!

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

마지막 단계! 아까 만든 tasksReducer reducer 함수를 컴포넌트에서 써보자. 임포트부터 해야 함. React에서 useReducer 훅을 임포트 해옵시다.

import { useReducer } from 'react'

이제 useState를 대체해보자.

const [tasks, setTasks] = useState(initialTasks);

이 코드를 useReducer로 바꾸면...

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

이렇게 된다.

useReduceruseState랑 비슷하게 생겼다 - state 초기값을 인자로 넘겨주고, state 값이랑 state를 설정하는 함수를 돌려준다. (useReducer에서는 dispatch 함수다. 요것이 차이점)

useReducer의 인자 2개 :

  1. reducer 함수
  2. state 초기값

리턴하는 것 :

  1. state 스러운 값
  2. dispatch 함수 → 사용자 액션을 reducer에 보내주기(dispatch) 위해

아래는 완성본.

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

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); // 인자 1 : reducer 함수(아래있음, 보통은 다른 파일에). 인자 2 : 초기 state값

  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 (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

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},
];

관심사의 분리

이제

  • 이벤트 핸들러는 무슨일인고 만 관여함
    어떻게? action을 dispatch 했기 때문.
  • reducer 함수는 state 업데이트 만 관여

useState - useReducer 비교

  • 코드 사이즈 : useState가 더 적음. 대신 useReducer는 재사용할 때 더 적어짐.
  • 가독성 : useState가 더 읽기 편함. 대신 useReducer는 컴포넌트가 커졌을 때 관심사 분리가 좋아서 가독성도 좋아짐
  • 디버깅 : 코드 양은 더 많지만, useReducer의 reducer 함수에 console.log() 찍으면 모든 state 업데이트의 이유를 볼 수 있음. (action 있으니깐!)
  • 테스팅 : reducer 함수는 pure function 이다! 컴포넌트에서 빼서 따로 테스팅 할 수 있어서 좋다.
  • 취향 차이 : 이건 왜써놓은거ㅋㅋ

리액트는 이럴 때 useReducer를 추천한다 :

  • 몇몇 컴포넌트에서 state 업데이트가 이상한 버그가 자주 나올 때
  • 코드에 구조 더하고 싶을 때

= 모든 곳에 useReducer를 쓸 필요는 없다!

reducers 잘 쓰기

이 두가지를 잘 기억해라

  1. pure function. setState처럼 Reducers는 렌더링 중에 돌아간다! (액션은 다음 렌더링까지 큐 됨) 그니까 부작용들 없이 작동해야 한다는 말임. 부작용 ? 컴포넌트 밖의 변수들을 조작하면 안된다는 뜻. 객체나 배열 건드릴 때 조심할 것.
  2. action은 하나씩만 설명할 것. 데이터의 여러군데를 건드리는 action이어도, 하나의 user interaction 만 지정해 줘야 함.
    예를 들어, 사용자가 [리셋하기] 버튼을 눌렀을 때, 5개의 reducer 상태 필드가 바뀐다고 치자. 하나의 reset_form action을 dispatch 해주는게 맞지, 5개의 set_field action을 보내주면 복잡하고 읽기 어려워진다는 것이다. 디버깅 꿀팁!

useImmerReducer 훅

도 있다. 객체나 배열을 업데이트 할 때, 원래는 shallow copy 만들어서 새로 상태를 만들어줘야 함. Immer는 보다 직관적으로 업데이트 하게 해준다. 어떻게? pusharr[i] = 이런 할당문을 쓰게 해준다는 것.

// 임포트부터
import {useImmerReducer} from 'use-immer';

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

function tasksReducer(draft, action) {
  switch (action.type) {
    case 'added': {
      // push 사용할 수 있다는 말씀이다 
      draft.push({
        id: action.id,
        text: action.text,
        done: false,
      });
      break;
    }
    case 'changed': {
      const index = draft.findIndex((t) => t.id === action.task.id);
      // arr[index] = 요러케 assign 할당해줄 수 있다는 말이다
      draft[index] = action.task;
      break;
    }
    case 'deleted': {
      // 이건 기존의 방식
      return draft.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

export default function TaskApp() {
  // useReducer 대신 useImmerReducer 사용
  const [tasks, dispatch] = useImmerReducer(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 (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

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},
];

다섯줄 요약

  • useState에서 useReducer로 바꾸려면 :
    1. 이벤트 핸들러로부터 action 분배 (dispatch)
    2. 현재 state와 action을 받아서 다음 state를 리턴하는 reducer 함수 작성
    3.useStateuseReducer로 바꾸기
  • reducer 쓰면 코드를 조금 더 쓰게 되긴 함. 그러나 디버깅과 테스팅에 좋다~
  • reducer는 pure function으로 사용해야 함.
  • 각 action은 하나의 user interaction만!
  • reducer로 객체/배열 다룰 때 mutate 방식으로 사용하려면 useImmerReducer 훅을 사용할 것.
profile
잡식성 누렁이 개발자

0개의 댓글