State Hooks (2) useReducer

Doozuu·2025년 3월 20일
0

React

목록 보기
26/30
post-thumbnail

useReducer

useReducer는 컴포넌트에 reducer를 더해주는 훅이다.

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

useReducer(reducer, initialArg, init?)

Reference
형태

import { useReducer } from 'react';

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

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

Parameters

  • reducer : state가 업데이트 되는 방식을 명시한 함수. 순수 함수여야 하고 state와 action을 인자로 받아 다음 state를 반환해야 한다.
  • initialArg : 초기 state 값. init 함수에 따라 계산된다.
  • init(optional) : 초기 state를 반환해야 하는 initializer 함수. init을 쓰지 않으면 초기 state 값은 initialArg로 설정되고, init을 쓰면 init(initialArg)로 설정된다.

Returns
useReducer는 두 개의 값을 가진 배열을 반환한다.
1. current state : 현재 state 값. 첫 번째 렌더링되는 경우 init(initialArg) 혹은 initialArg으로 설정된다.
2. dispatch function : state를 업데이트해주는 함수. 리렌더링을 발생시킨다.

주의사항

  • useReducer는 훅이므로 컴포넌트 최상단에서만 실행할 수 있다. (반복문이나 조건문 내부에서 호출하면 안 됨)
  • dispatch 함수를 useEffect deps에 포함시켜도 useEffect는 실행되지 않는다.
  • Strict Mode에서는 예상치 못한 실수를 잡아내기위해 React가 reducer와 initializer를 두 번씩 호출한다. (개발 중에만 실행되므로 프로덕션에는 영향을 미치지 않는다.) reducer와 initializer가 순수 함수라면 로직에 영향을 미치지 않고, 실행 결과중 하나는 무시된다.

dispatch function

useReducer에 의해 반환되는 dispatch 함수는 state를 다른 값으로 업데이트할 수 있게 해주고 리렌더링을 발생시킨다.
dispatch 함수에는 action만 넘겨주면 된다.

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

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

dispatch로 넘긴 action과 현재 state를 바탕으로 React가 reducer 함수를 실행해서 그 결과값을 다음 state로 설정한다.

Parameters

  • action : 유저의 동작을 의미한다.(add, delete, update,,)
    어떤 타입이든 상관없지만 일반적으로 type 속성을 가진 객체를 사용한다. (필요에 따라 그외 추가적인 속성들도 포함할 수 있음.)

Returns
dispatch 함수는 결과값을 반환하지 않는다.

주의사항

  • dispatch 함수는 setState와 마찬가지로 다음 렌더링에 state 값이 업데이트 된다. 따라서 dispatch 함수를 실행한 후 바로 state를 읽으면 이전 값을 얻게된다.
  • state에 새로 넘긴 값이 이전 값과 동일하면 React가 Object.is로 값을 비교하여 리렌더링을 건너뛴다. (자식 컴포넌트까지 포함)
  • React는 하나의 이벤트에 여러번의 리렌더링이 발생하는 것을 막기 위해 모든 이벤트 핸들러를 실행한 후에 set function을 호출한다. 드물게 화면을 더 빨리 업데이트해야 할 경우, 예를 들어 DOM에 접근해야 할 때는 flushSync를 사용할 수 있다.

Usage

Adding a reducer to a component

import { useReducer } from 'react';

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

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

값을 업데이트하고 싶을 때는 dispatch 함수를 이용한다.

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

useReducer는 useState와 매우 유사하지만 state 업데이트 로직을 이벤트 핸들러로부터 분리해 컴포넌트 외부로 보낸다는 차이점이 있다.
(useState와 useReducer를 비교한 부분은 맨 아래 부분 참조)

Writing the reducer function

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

각 action type별 처리 로직은 switch 문으로 작성하는게 컨벤션이다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        name: state.name,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      return {
        name: action.nextName,
        age: state.age
      };
    }
  }
  throw Error('Unknown action: ' + action.type);
}

action은 어떤 형태로든 쓸 수 있지만 type 속성을 가진 객체로 쓰는 것이 컨벤션이다.
(다음 state를 계산하기 위한 최소한의 정보만 포함해야 한다.)

function Form() {
  const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 });
  
  function handleButtonClick() {
    dispatch({ type: 'incremented_age' });
  }

  function handleInputChange(e) {
    dispatch({
      type: 'changed_name',
      nextName: e.target.value
    });
  }
  // ...

주의사항
state는 read only이므로 직접 mutate하지 말고 replace하기

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;
    }
function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ Instead, return a new object
      return {
        ...state,
        age: state.age + 1
      };
    }

replace하는게 복잡할 때는 Immer 라이브러리를 사용해볼 수 있다.
아래처럼 되어 있으면 상대적으로 이해하기가 어려워지므로 이때 Immer 라이브러리를 사용하면 가독성을 개선할 수 있다.

 case 'edited_message': {
      return {
        ...state,
        messages: {
         ...state.messages,
         [state.selectedId]: action.message
      }
      };
    }


사용 예제

import { useReducer } from 'react';

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        name: state.name,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      return {
        name: action.nextName,
        age: state.age
      };
    }
  }
  throw Error('Unknown action: ' + action.type);
}

const initialState = { name: 'Taylor', age: 42 };

export default function Form() {
  const [state, dispatch] = useReducer(reducer, initialState);

  function handleButtonClick() {
    dispatch({ type: 'incremented_age' });
  }

  function handleInputChange(e) {
    dispatch({
      type: 'changed_name',
      nextName: e.target.value
    }); 
  }

  return (
    <>
      <input
        value={state.name}
        onChange={handleInputChange}
      />
      <button onClick={handleButtonClick}>
        Increment age
      </button>
      <p>Hello, {state.name}. You  are {state.age}.</p>
    </>
  );
}

배열 쓰는 경우

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

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

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

Avoiding recreating the initial state

React는 initial state를 한 번만 저장하고 다음 렌더 시에는 무시한다.
아래 예제에서 createInitialState(username)는 첫 렌더링 시에 한 번만 쓰이지만 매 렌더링마다 호출된다.

function createInitialState(username) {
  // ...
}

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

이는 성능에 영향을 미칠 수 있기 때문에 initializer 함수를 세 번째 인자에 넣어주는게 좋다.

function createInitialState(username) {
  // ...
}

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

Troubleshooting

1. action을 dispatch했는데 이전 값이 로그에 찍히는 경우

dispatch 함수를 실행해도 실행중인 코드의 state를 바로 변경하지 않는다.
새로운 state로 업데이트하게끔 요청을 보낼 뿐 이미 실행중인 이벤트 핸들러의 변수에 영향을 미치진 않는다.

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

업데이트된 값을 보고 싶을 때는 reducer를 호출해서 확인하기

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

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

2. action을 dispatch했는데 화면에 업데이트되지 않을 때

React는 다음 state가 이전 state와 동일하면 업데이트하지 않는다.
(보통 객체나 배열을 직접적으로 바꿀 때 발생함)

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;
    }
    // ...
  }
}

mutate하지 말고 replace할 것.

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
      };
    }
    // ...
  }
}

3. reducer state 일부가 dispatch 후에 undefined가 될 때

새로운 state를 반환할 때 모든 case에서 기존 값을 복사하도록 하기.
아래에서 ...state를 빼먹으면 age 필드만 남고 나머지는 없어진다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        ...state, // Don't forget this!
        age: state.age + 1
      };
    }
    // ...

4. reducer state 전체가 dispatch 후에 undefined가 되는 경우

state를 return하는 것을 까먹거나 매치되는 type이 없을 때 발생한다.
이유를 찾기 위해 switch문 마지막에 throw error를 추가할 것. (혹은 TypeScript를 이용해서 실수를 잡아낼 수 있음)

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ...
    }
    case 'edited_name': {
      // ...
    }
  }
  throw Error('Unknown action: ' + action.type);
}

useState vs useReducer

  • 코드 사이즈 : useState < useReducer
    그렇지만 useReducer를 사용하면 코드를 쪼갤 수 있음.
  • 가독성 : useState > useReducer
    로직이 복잡해질 경우에는 파악하는 것이 어려워지므로 useReducer를 사용하는 것이 좋을 수도 있음.
  • 디버깅 : useState < useReducer
    useState를 쓰면 어디서 왜 문제가 발생했는지 파악하는게 어려울 수 있음. useReducer를 쓰면 원인 파악이 좀 더 쉬워짐.
  • 테스팅 : useState < useReducer
    reducer는 순수 함수이므로 분리해서 테스트하기 쉬움.
  • 개인적 선호도 : 개인 선호도에 따라 선택해도 상관없음.

부정확한 state 업데이트로 인해 버그를 겪을 때 reducer를 쓰는 것을 권장한다.
모든 것에 reducer를 쓸 필요는 없고 섞어서 써도 된다.


reducer를 쓸 때 유의해야 할 점

1. reducer는 순수 함수여야 한다.
state 업데이트 함수와 비슷하게 렌더링 중에 실행되므로 순수 함수여야 한다.

2. action은 한 가지 동작만을 설명해야 한다.
만약에 5개의 필드를 관리하는 폼에서 reset을 누를 경우 5개의 개별 set_field 액션을 디스패치하기보다는 하나의 reset_form 액션을 디스패치하는게 더 합리적이다.


useState로 useReducer 구현해보기

import { useState } from 'react';

export function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);


  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }
  
  // 아래가 보다 정확한 표현. 상태가 업데이트되는 시점에 최신 상태 값을 보장할 수 있음.
  // 여러 dispatch 호출이 배치 처리되면서 상태 값이 불일치할 수 있는 문제를 방지할 수 있음.
  function dispatch(action) {
  	setState((s) => reducer(s, action));
  }

  return [state, dispatch];
}

자료

React useReducer 공식 문서 : https://react.dev/reference/react/useReducer
https://react.dev/learn/extracting-state-logic-into-a-reducer#comparing-usestate-and-usereducer

profile
모든게 새롭고 재밌는 프론트엔드 새싹

0개의 댓글