React 스터디 5주차 - useReducer 이해하기

Yunes·2023년 8월 29일
1

리액트스터디

목록 보기
10/18
post-thumbnail

서론

global state 에 대해 알아볼때 useContext 다음에 redux 를 알아보려 했다. 그런데 이게 useReducer 훅과 관련이 있어 보였고 useContext 를 통해 global state 관리시 useReducer 와 함께 사용하여 상태관리를 어떻게 하는지 찾아보려 했기에 useReducer 훅에 대해 먼저 알아보게 되었다.

reducer 란?

useReducer 는 컴포넌트에 reducer 를 추가할 수 있게 해주는 React Hook 이다.

그렇다면 reducer 는 뭘까?

state 로직을 추출해서 reducer 에 맡기자

많은 이벤트 핸들러에 걸쳐 많은 상태 업데이트가 있는 컴포넌트는 혼란스러울 수 있다. 이럴때 상태 업데이트 로직을 컴포넌트 바깥의 reducer 라 부르는 하나의 함수에 통합할 수 있다.

컴포넌트를 업데이트함에 따라 구조가 복잡해지면 한눈에 상태들이 어떻게 업데이트되는지 알아보기 어려워질 것이다.

react 공식문서 에 할일을 추가, 삭제, 수정하는 TaskApp 컴포넌트를 예시로 소개하고 있다.

각각의 이벤트핸들러는 setTasks 를 통해 상태를 업데이트하는데 컴포넌트 규모가 커지면서 이러한 상태 로직이 전체적으로 뿌려지며 복잡성을 띄게 된다. 이런 문제를 해결하기 위해서 상태 로직을 컴포넌트 바깥의 reducer 라 불리는 하나의 함수로 이동시키자.

useStateuseReducer 로 migration 하는 방법은 다음과 같다.

migrate useState to useReducer

  1. 상태를 설정하는 대신 액션을 파견 dispatch 하자.

  2. reducer 함수를 작성하자.

  3. 컴포넌트에서 reducer 를 사용하자.

1. setting state -> dispatching action

이전 코드에서 각 이벤트 핸들러는 상태를 설정함으로써 무엇을 해야하는지 명시하고 있다.

상태 설정 로직을 제거한다.

다음 이벤트 핸들러는 아래와 같은 로직 정도만 남긴다.

  • handleAddTask(text) 각 이벤트 핸들러는 add 를 클릭할때 호출

  • handleChangeTask(text) 각 이벤트 핸들러는 change 를 클릭할때, task 를 토글할때 호출

  • handleDeleteTask(text) 각 이벤트 핸들러는 delete 를 클릭할때 호출

reducer 로 상태를 관리하는 것은 상태를 직접적으로 설정하는 것과 약간 다르다.

reducer 는 React 에게 상태를 설정함으로써 무엇을 할지 알려주는 것보다 event handler 로부터 action 을 dispatch 해서 유저가 무엇을 했는지 명시한다.

그래서 이벤트 핸들러를 통해 상태를 설정하는 대신 할일을 추가 , 할일을 변경 , 할일을 삭제 , 하는 액션을 dispatch 한다. 이는 유저의 의도를 좀더 잘 설명한다.

setting statedispatch action

코드 출처 : react.dev

dispatch 를 통해 전달하는 객체를 action 이라고 한다.

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

액션은 어떤 형태든 될 수 있으나 convention 에 의해 일반적으로 type 이라는 property 에 문자열로 무엇이 발생했는지를 설명한다. 그 외엔 추가적인 정보를 전달할 수 있다.

dispatch({
  // specific to component
  type: 'what_happened',
  // other fields go here
});

2. write reducer

앞에서 상태를 설정하는 것을 action 을 dispatch 하는 것으로 바꿨다. 이제 reducer 라는 함수를 만들어야 한다.

reducer 함수는 상태로직을 관리하게 될 함수다. 현재 상태와 action 객체를 인자로 받아서 다음 상태를 반환한다.

function yourReducer(state, action) {
  // return next state for React to set
}

그러면 React 는 reducer 가 반환한 대로 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);
  }
}

형태를 봤을때는 SOLID 의 Open-Closed 원칙에 위배되는 것으로 보인다. 구조가 좀 불편하지만 지금은 reducer 가 어떻게 돌아가는지 이해한 뒤 이후에 다시 refactoring 해보자.

reducer 함수가 인자로 현재 상태를 받기 때문에 컴포넌트 바깥에서도 reducer 함수를 선언할 수 있다. 이는 의존성을 낮추고 코드를 읽기 쉽게 해준다.

일반적으로 reducer 내부에선 if/else 보단 switch 구문을 사용한다고 한다.

use if/else reduceruse switch reducer

코드 출처 : react.dev

코드가 좀더 늘어난 것 같지만 보통 이런 경우 switch 문의 가독성이 더 좋다.


이전 포스트 에서 reduce 에 대한 내용을 정리했던 부분을 참고했다.

reducer 는 실제로 컴포넌트의 코드 양을 줄이나 실제로는 array 의 연산자 reduce() 로부터 이름이 지어졌다.

reduce() 는 지금까지의 결과와 현재 아이템을 받고 다음 결과를 반환한다. React 의 reducer 도 같은 아이디어를 갖는다. 대신 지금까지의 state 와 action 을 받고 다음 상태를 반환한다.

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

혹은 초기 상태와 액션의 배열을 갖고 최종 상태를 계산할수도 있다.

바람직하진 않지만 React 의 reducer 가 비슷하게 동작하니 참고할만한 예시였다.

3. use reducer

이제 만든 reducer 를 컴포넌트에 연결해야한다. react 에서 useReducer 훅을 import 해서 사용하자.

import { useState , useReducer } from 'react';

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

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

이제 useStateuseReducer 로 refactoring 할 수 있게 되었다. useReduceruseState 와 형태가 거의 비슷하나 상태를 설정하는 함수가 dispatch 가 되고 인자로 reducer 와 초기값을 전달한다는 차이점이 있다.

useState 와 useReducer 비교하기

reducer 가 단점이 없는게 아니다. 상황에 따라 useStateuseReducer 중 무엇을 사용할지 비교해보자.

코드 크기

  • 보통 useState 를 사용하는게 코드의 양이 더 적다. useReducerreducerdispatch action 에 대해 추가로 코드를 짜야 한다. 물론 많은 이벤트 핸들러가 있고 상태를 변경하는 비슷한 상황에선 useReducer 를 사용하는게 코드를 더 줄여준다.

가독성

  • useState 가 상태를 업데이트할때 읽기 편하다. 그런데 구조가 복잡해지면 이해하기 어려워진다. 이런 경우 seReducer는 이벤트 핸들러에서 무엇이 발생하여 업데이트하는 로직을 깔끔하게 분리할 수 있다.

debugging

  • 디버깅시 useState 와 달리 useReducer 는 reducer 에서 상태변경을 모아서 하고 있기 때문에 원인을 파악하기 쉽다. 물론 useState 보다 코드를 더 짜야 한다.

testing

  • 테스트할때 reducer 는 순수함수이기 때문에 컴포넌트에 의존하고 있지 않아 독립적으로 분리하여 테스트할 수 있다.

만약 상태를 업데이트하는데 오류를 자주 만난다면 reducer 사용을 추천한다. 다만 항상 reducer 를 쓸 필요 없다. 심지어 useStateuseReducer 를 같은 컴포넌트에서 사용해도 된다.

reducer 사용시 주의점

  • reducer 는 순수함수여야 한다. state 를 변경하는 함수처럼 reducer 는 렌더링 도중 실행된다. 이는 같은 입력에 대해 항상 같은 출력을 반환해야 함을 의미한다. reducer 는 요청, 스케줄 시간 만료를 보내면 안되고 컴포넌트 바깥에 영향을 끼치는 side effect 를 발생시켜서는 안된다. reducer 는 객체와 배열을 변형없이 업데이트해야 한다.

  • action 이 데이터에서 많은 변경을 야기해도 각각의 action 은 하나의 유저 상호작용을 나타내야 한다. 예를 들어 reducer 에 의해 다수의 field 를 초기화하는 경우 여러개의 set_field action 을 취하는 것보다 하나의 reset action 을 dispatch 하는게 더 합리적이다.

Immer 로 간결한 reducer 만들기

객체나 배열을 상태로 사용시 업데이트할때 Immer library 를 사용하면 reducer 를 더욱 간결하게 사용할 수 있다. react 에선 배열이나 객체를 업데이트 할 때 직접 수정하면 안되고 불변성을 지켜주며 업데이트해줘야 한다.

그래서 ...spread 문법을 통해 새로운 객체를 만들어주거나

const object = {
  a: 1,
  b: 2
};

const nextObject = {
  ...object,
  b: 3
};

배열의 경우 push, splice 등의 함수 사용, n 번째 항목의 직접수정은 하면 안되고 concat, filter, map 등의 함수를 사용해야 한다.

const todos = [
  {
    id: 1,
    text: '할 일 #1',
    done: true
  },
  {
    id: 2
    text: '할 일 #2',
    done: false
  }
];

const inserted = todos.concat({
  id: 3,
  text: '할 일 #3',
  done: false
});

const filtered = todos.filter(todo => todo.id !== 2);

const toggled = todos.map(
  todo => todo.id === 2
    ? {
      ...todo,
      done: !todo.done,
    }
    : todo
);

객체의 구조가 복잡해지면 ... spread 문법 을 사용하면서 더욱 보기 어려워질 수 있다. 이때 Immer 라이브러리를 사용하면 변형해도 안전한 특별한 draft 객체를 제공한다. Immer 는 draft 에 대한 변경사항으로 상태의 복사본을 생성한다. 이러한 이유로 불변성을 신경쓰지 않고 업데이트를 해줄 수 있으니 첫 번째 인자로 받는 상태를 직접 수정할 수 있고 상태를 반환힐 필요도 없게 되는 것이다.

밸로퍼트와 함께하는 모던 리액트에 한눈에 변화를 보기 쉬운 코드가 있었다.

기존

const nextState = {
  ...state,
  posts: state.posts.map(post =>
    post.id === 1
      ? {
          ...post,
          comments: post.comments.concat({
            id: 3,
            text: '새로운 댓글'
          })
        }
      : post
  )
};

Immer library 사용시

const nextState = produce(state, draft => {
  const post = draft.posts.find(post => post.id === 1);
  post.comments.push({
    id: 3,
    text: '와 정말 쉽다!'
  });
});

코드 출처 : 벨로퍼트와 함께하는 모던 리액트

단, Immer library 를 사용시 성능에 약간 하락이 있으니 무턱대고 항상 사용하면 안되고 상황에 따라 사용해야 할 것 같다.

코드 출처 : react.dev

Immer 를 사용하고자 한다면 다음의 명령어를 통해 설치할 수 있다.

npm install immer use-immer

대표적인 API 로 useImemruseImmerReducer 가 있는데 각각 useState, useReducer 에 Imemr 를 사용한 버전의 변화가 있다.

즉, 구조는 거의 유사한데 기존에 불변성을 위해 직접 state 를 변경하지 않았던 부분을 draft 를 직접 수정하는 것과 state 를 반환하지 않는다는 차이점이 있다.

useImmer 예시

import React from "react";
import { useImmer } from "use-immer";


function App() {
  const [person, updatePerson] = useImmer({
    name: "Michel",
    age: 33
  });

  function updateName(name) {
    updatePerson(draft => {
      draft.name = name;
    });
  }

  function becomeOlder() {
    updatePerson(draft => {
      draft.age++;
    });
  }

  return (
    <div className="App">
      <h1>
        Hello {person.name} ({person.age})
      </h1>
      <input
        onChange={e => {
          updateName(e.target.value);
        }}
        value={person.name}
      />
      <br />
      <button onClick={becomeOlder}>Older</button>
    </div>
  );
}

useImmerReducer 예시

import React from "react";
import { useImmerReducer } from "use-immer";

const initialState = { count: 0 };

function reducer(draft, action) {
  switch (action.type) {
    case "reset":
      return initialState;
    case "increment":
      return void draft.count++;
    case "decrement":
      return void draft.count--;
  }
}

function Counter() {
  const [state, dispatch] = useImmerReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
}

Immer 라이브러리를 사용하면 ... spread , concat , filter , map 등으로 원본에 영향을 끼치지 않도록 하던 것을 Immer 가 복사본을 통해 draft 를 직접 수정할 수 있게 해주니 push, splice, arr[i] = 할당 등을 사용할 수 있다.


자.. 이제 reducer 가 무엇인지 이해가 좀 된 것 같다. 그럼 useReducer 훅에 대해 알아보자.

useReducer

const [state, dispatch] = useReducer(reducer, initialArg, init?)

reducer

  • reducer 함수는 상태를 어떻게 업데이트할지 명시한다. 순수함수여야 하며 인자로 현재 상태와 action 을 받고 다음 상태를 반환한다. 현재 상태와 action 은 어떤 타입도 될 수 있다.

initialArg

  • 초기 상태가 계산된 값이다. 어떤 타입도 될 수 있다. 초기 상태가 계산되는 방법은 다음 init 인자에 따라 다르다.

init

  • 초기 상태를 반환하는 초기화 함수다. 만약 명시되지 않으면 초기 상태는 initialArg 가 된다. 그렇지 않으면 초기 상태는 init(initialArg) 의 호출 반환값이 된다.

reducer 를 사용해서 상태를 관리하고 싶다면 useReducer 를 컴포넌트 상단에서 호출하자.

import { useReducer } from 'react';

function reducer(state, action) {
  // ...
}

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, { age: 42 });
  // ...

useReducer 의 반환값

useReducer 는 2개의 값을 갖는 배열을 반환한다.

  • 현재 상태. 첫 렌더링 동안 init(initialArg) 혹은 initialArg 로 설정된다.

  • 상태를 다른 값으로 업데이트 하고 재렌더링을 유발하는 dispatch 함수

dispatch 함수

dispatch 함수는 상태를 변경하고 재렌더링을 유발한다. dispatch 함수의 유일한 인자로 action 을 전달해야 한다.

const [state, dispatch] = useReducer(reducer, { age: 42 });

function handleClick() {
  dispatch({ type: 'incremented_age' });
  // ...

react 는 제공한 현재 상태와 dispatch 로 전달한 action 을 통해 reducer 함수의 호출 결과로 다음 상태를 설정한다.

action : 유저에 의해 수행되는 동작을 말한다. 어떤 타입이든 될 수 있다.일반적으로 action 은 type 프로퍼티를 갖는 객체이고 다른 프로퍼티는 추가적인 정보를 선택적으로 갖는다.

dispatch 는 반환값을 갖지 않는다.

만약 dispatch 를 통해 새로 구한 상태가 현재 상태와 동일하다면 리액트는 컴포넌트를 재렌더링하지 않는다.

React 는 상태 업데이트를 일괄적으로 처리한다. 즉, 모든 이벤트 핸들러가 실행되고 그들의 set 함수를 호출한 뒤에 화면을 업데이트한다. 이런 batch state update 기능이 하나의 이벤트 동안 여러번 재렌더링되는 것을 막아준다.

주의점

state 는 읽기 전용이니 객체나 배열의 state 를 직접 바꾸면 안된다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // 🚩 Don't mutate an object in state like this:
      state.age = state.age + 1;
      return state;
    }

대신 항상 reducer 를 통해 새로운 객체를 반환하자.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ Instead, return a new object
      return {
        ...state,
        age: state.age + 1
      };
    }

초기 상태를 재생성하지 말것

// 올바르지 않은 예시
function createInitialState(username) {
  // ...
}

function TodoList({ username }) {
  const [state, dispatch] = useReducer(reducer, createInitialState(username));
  // ...

애초에 React 는 첫 렌더링때만 초기 상태를 저장하고 다음 렌더링때 이 초기 상태를 사용한다. 그러는 대신 useReducer 의 세번째 인자로 init 함수를 전달하자.

// 권장되는 예시
function createInitialState(username) {
  // ...
}

function TodoList({ username }) {
  const [state, dispatch] = useReducer(reducer, username, createInitialState);
  // ...

createInitialState() 가 아닌 createInitialState 를 세번째 인자로 전달하는 부분에 주목하자. 초기화 이후 초기 상태는 다시 만들어지지 않는다.

문제해결

action 을 dispatch 했는데 이전 상태를 로깅한다

dispatch 함수를 호출하는 것은 코드를 실행하는 동안 상태를 바꾸지 않는다.

function handleClick() {
  console.log(state.age);  // 42

  dispatch({ type: 'incremented_age' }); // Request a re-render with 43
  console.log(state.age);  // Still 42!

  setTimeout(() => {
    console.log(state.age); // Also 42!
  }, 5000);
}

이는 이전에 useEffect 원리 파헤치기 2편 에서 정리했던 것처럼 상태는 스냅샷처럼 행동하기 때문인데 각각의 렌더링은 그때 마다의 상태를 갖는다. 마치 영상중 스크린샷을 찍은 순간의 상태를 각 초마다 다르게 갖는 것처럼 말이다.

dispatch 는 action 을 통해 상태를 바꿔 재렌더링을 유발한다. 이때 새로 갖게 된 상태는 그 렇게 재렌더링이 된 다음 렌더링 시점에서의 상태이지 현재의 상태가 아니다.

만약 다음상태를 알고싶다면 reducer 함수가 action 을 통해 새로운 상태를 반환하므로 이를통해 수동으로 확인할 수는 있다.

const action = { type: 'incremented_age' };
dispatch(action);

const nextState = reducer(state, action);
console.log(state);     // { age: 42 }
console.log(nextState); // { age: 43 }

action 을 dispatch 했는데 화면이 업데이트 되지 않는다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // 🚩 Wrong: mutating existing object
      state.age++;
      return state;
    }
    case 'changed_name': {
      // 🚩 Wrong: mutating existing object
      state.name = action.nextName;
      return state;
    }
    // ...
  }
}

Immer 라이브러리를 사용하지 않는한 불변성을 유지해야 하기에 객체나 배열의 프로퍼티를 직접 수정해서는 안된다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ Correct: creating a new object
      return {
        ...state,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      // ✅ Correct: creating a new object
      return {
        ...state,
        name: action.nextName
      };
    }
    // ...
  }
}

대신 ...spread 문법을 통해 새로운 객체를 반환할 수 있다.

레퍼런스

docs
react.dev - extracting state logic into a reducer
mdn - reducer()
use-immer library
wiki
벨로퍼트와 함께하는 모던 리액트

profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

0개의 댓글