useReducer

김동현·2026년 3월 17일

useReducer

소개

useReducer는 컴포넌트에 리듀서를 추가할 수 있게 해주는 React Hook이에요.

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

목차


레퍼런스 {/reference/}

useReducer(reducer, initialArg, init?) {/usereducer/}

컴포넌트의 최상위 레벨에서 useReducer를 호출해서 리듀서로 상태를 관리하세요.

import { useReducer } from 'react';

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

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

아래에서 더 많은 예제를 확인하세요.

매개변수 (Parameters) {/parameters/}

  • reducer: 상태가 어떻게 업데이트되는지를 지정하는 리듀서 함수예요. 순수 함수여야 하고, state와 action을 인자로 받아서 다음 state를 반환해야 해요. state와 action은 어떤 타입이든 될 수 있어요.
  • initialArg: 초기 state를 계산할 때 사용되는 값이에요. 어떤 타입의 값이든 될 수 있어요. 이 값에서 초기 state가 어떻게 계산되는지는 다음 init 인자에 따라 달라져요.
  • 선택적 init: 초기 state를 반환해야 하는 초기화 함수예요. 지정하지 않으면 초기 state가 initialArg로 설정돼요. 지정하면 초기 state가 init(initialArg)를 호출한 결과로 설정돼요.

반환값 (Returns) {/returns/}

useReducer는 정확히 두 개의 값을 가진 배열을 반환해요:

  1. 현재 state. 첫 번째 렌더링 중에는 init(initialArg) 또는 initialArg (init이 없는 경우)로 설정돼요.
  2. state를 다른 값으로 업데이트하고 리렌더링을 트리거할 수 있는 dispatch 함수.

주의사항 (Caveats) {/caveats/}

  • useReducer는 Hook이기 때문에, 컴포넌트의 최상위 레벨 또는 커스텀 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그렇게 해야 한다면, 새 컴포넌트를 분리해서 state를 그쪽으로 옮기세요.
  • dispatch 함수는 안정적인 정체성(stable identity)을 가지고 있어서, Effect 의존성에서 생략하는 걸 자주 볼 수 있지만, 포함해도 Effect가 발동되지 않아요. 린터가 에러 없이 의존성을 생략할 수 있게 해준다면, 그렇게 해도 안전해요. Effect 의존성 제거하기에 대해 더 알아보세요.
  • Strict Mode에서는 React가 우발적인 불순물(impurity)을 찾는 데 도움이 되도록 리듀서와 초기화 함수를 두 번 호출해요. 이건 개발 환경에서만 일어나는 동작이고, 프로덕션에는 영향을 주지 않아요. 리듀서와 초기화 함수가 순수하다면(당연히 그래야 하고요), 이게 로직에 영향을 주지 않을 거예요. 두 번의 호출 중 하나의 결과는 무시돼요.

dispatch 함수 {/dispatch/}

useReducer가 반환하는 dispatch 함수를 사용하면 state를 다른 값으로 업데이트하고 리렌더링을 트리거할 수 있어요. dispatch 함수에 유일한 인자로 action을 전달해야 해요:

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

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

React는 현재 statedispatch에 전달한 action을 가지고 여러분이 제공한 reducer 함수를 호출한 결과를 다음 state로 설정할 거예요.

매개변수 (Parameters) {/dispatch-parameters/}

  • action: 사용자가 수행한 액션이에요. 어떤 타입의 값이든 될 수 있어요. 관례적으로, action은 보통 그것을 식별하는 type 프로퍼티가 있는 객체이고, 선택적으로 추가 정보를 담은 다른 프로퍼티들을 가져요.

반환값 (Returns) {/dispatch-returns/}

dispatch 함수는 반환값이 없어요.

주의사항 (Caveats) {/setstate-caveats/}

  • dispatch 함수는 다음 렌더링을 위한 state 변수만 업데이트해요. dispatch 함수를 호출한 후에 state 변수를 읽으면, 호출 전에 화면에 있던 이전 값을 여전히 얻게 돼요.

  • 여러분이 제공한 새 값이 현재 state와 동일하다면 (Object.is 비교로 판단), React는 컴포넌트와 그 자식들의 리렌더링을 건너뛸 거예요. 이건 최적화예요. React가 결과를 무시하기 전에 컴포넌트를 호출해야 할 수도 있지만, 여러분의 코드에는 영향을 주지 않아야 해요.

  • React는 state 업데이트를 일괄 처리(batch)해요. 모든 이벤트 핸들러가 실행되고 set 함수들을 호출한 후에 화면을 업데이트해요. 이렇게 하면 단일 이벤트 중에 여러 번 리렌더링되는 걸 방지해요. 드문 경우에 React가 더 일찍 화면을 업데이트하도록 강제해야 한다면(예: DOM에 접근하기 위해), flushSync를 사용할 수 있어요.


사용법 (Usage) {/usage/}

컴포넌트에 리듀서 추가하기 {/adding-a-reducer-to-a-component/}

컴포넌트의 최상위 레벨에서 useReducer를 호출해서 리듀서로 state를 관리하세요.

import { useReducer } from 'react';

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

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

useReducer는 정확히 두 개의 항목이 있는 배열을 반환해요:

  1. 이 state 변수의 현재 state, 처음에는 여러분이 제공한 초기 state로 설정돼요.
  2. 상호작용에 대한 응답으로 state를 변경할 수 있는 dispatch 함수.

화면에 표시되는 것을 업데이트하려면, 사용자가 무엇을 했는지를 나타내는 객체(action이라고 불러요)를 전달하면서 dispatch를 호출하세요:

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

React는 현재 state와 action을 여러분의 리듀서 함수에 전달할 거예요. 리듀서가 다음 state를 계산해서 반환하면, React가 그 다음 state를 저장하고, 컴포넌트를 그것으로 렌더링하고, UI를 업데이트해요.

import { useReducer } from 'react';

function reducer(state, action) {
  if (action.type === 'incremented_age') {
    return {
      age: state.age + 1
    };
  }
  throw Error('Unknown action.');
}

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

  return (
    <>
      <button onClick={() => {
        dispatch({ type: 'incremented_age' })
      }}>
        Increment age
      </button>
      <p>Hello! You are {state.age}.</p>
    </>
  );
}
button { display: block; margin-top: 10px; }

useReduceruseState와 매우 비슷하지만, state 업데이트 로직을 이벤트 핸들러에서 컴포넌트 밖의 단일 함수로 옮길 수 있게 해줘요. useStateuseReducer 중 선택하기에 대해 더 읽어보세요.


리듀서 함수 작성하기 {/writing-the-reducer-function/}

리듀서 함수는 이렇게 선언해요:

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

그런 다음 다음 state를 계산하고 반환하는 코드를 채워 넣어야 해요. 관례적으로, switch으로 작성하는 게 일반적이에요. switch의 각 case에서 다음 state를 계산하고 반환하세요.

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은 어떤 형태든 될 수 있어요. 관례적으로, 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
    });
  }
  // ...

action 타입 이름은 컴포넌트에 지역적(local)이에요. 각 action은 데이터에 여러 변경을 야기하더라도 단일 상호작용을 설명해요. state의 형태는 임의적이지만, 보통 객체나 배열이 될 거예요.

더 자세한 내용은 state 로직을 리듀서로 추출하기를 읽어보세요.

⚠️ 주의

State는 읽기 전용이에요. state에 있는 객체나 배열을 수정하지 마세요:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // 🚩 이렇게 state의 객체를 변경하지 마세요:
      state.age = state.age + 1;
      return state;
    }

대신, 리듀서에서 항상 새 객체를 반환하세요:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ 대신, 새 객체를 반환하세요
      return {
        ...state,
        age: state.age + 1
      };
    }

더 자세한 내용은 state에서 객체 업데이트하기state에서 배열 업데이트하기를 읽어보세요.

기본 useReducer 예제들

Form (객체) {/form-object/}

이 예제에서 리듀서는 nameage 두 개의 필드를 가진 state 객체를 관리해요.

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>
    </>
  );
}
button { display: block; margin-top: 10px; }

Todo 리스트 (배열) {/todo-list-array/}

이 예제에서 리듀서는 할일(task) 배열을 관리해요. 배열은 변경(mutation) 없이 업데이트해야 해요.

// src/App.js
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 }
];
// src/AddTask.js
import { useState } from 'react';

export default function AddTask({ onAddTask }) {
  const [text, setText] = useState('');
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        onAddTask(text);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  tasks,
  onChangeTask,
  onDeleteTask
}) {
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task
            task={task}
            onChange={onChangeTask}
            onDelete={onDeleteTask}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ task, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            onChange({
              ...task,
              text: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          onChange({
            ...task,
            done: e.target.checked
          });
        }}
      />
      {taskContent}
      <button onClick={() => onDelete(task.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }

Immer로 간결한 업데이트 로직 작성하기 {/writing-concise-update-logic-with-immer/}

변경(mutation) 없이 배열과 객체를 업데이트하는 게 번거롭게 느껴진다면, Immer 같은 라이브러리를 사용해서 반복적인 코드를 줄일 수 있어요. Immer를 사용하면 마치 객체를 직접 변경하는 것처럼 간결한 코드를 작성할 수 있지만, 내부적으로는 불변 업데이트를 수행해요:

// src/App.js
import { useImmerReducer } from 'use-immer';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

function tasksReducer(draft, action) {
  switch (action.type) {
    case 'added': {
      draft.push({
        id: action.id,
        text: action.text,
        done: false
      });
      break;
    }
    case 'changed': {
      const index = draft.findIndex(t =>
        t.id === action.task.id
      );
      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() {
  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 },
];
// src/AddTask.js
import { useState } from 'react';

export default function AddTask({ onAddTask }) {
  const [text, setText] = useState('');
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        onAddTask(text);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  tasks,
  onChangeTask,
  onDeleteTask
}) {
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task
            task={task}
            onChange={onChangeTask}
            onDelete={onDeleteTask}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ task, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            onChange({
              ...task,
              text: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          onChange({
            ...task,
            done: e.target.checked
          });
        }}
      />
      {taskContent}
      <button onClick={() => onDelete(task.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

초기 state 재생성 피하기 {/avoiding-recreating-the-initial-state/}

React는 초기 state를 한 번 저장하고 다음 렌더링에서는 무시해요.

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

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

createInitialState(username)의 결과는 초기 렌더링에서만 사용되지만, 여전히 매 렌더링마다 이 함수를 호출하고 있어요. 큰 배열을 생성하거나 비싼 계산을 수행하는 경우 낭비가 될 수 있어요.

이걸 해결하려면, useReducer의 세 번째 인자로 초기화(initializer) 함수를 전달할 수 있어요:

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

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

createInitialState()가 아니라, 함수 자체createInitialState를 전달하고 있다는 걸 주목하세요. 이렇게 하면 초기화 이후에 초기 state가 다시 생성되지 않아요.

위의 예제에서 createInitialStateusername 인자를 받아요. 초기화 함수가 초기 state를 계산하는 데 아무 정보도 필요 없다면, useReducer의 두 번째 인자로 null을 전달할 수 있어요.

초기화 함수 전달과 초기 state 직접 전달의 차이

초기화 함수 전달하기 {/passing-the-initializer-function/}

이 예제는 초기화 함수를 전달하므로, createInitialState 함수는 초기화 중에만 실행돼요. input에 타이핑하는 것처럼 컴포넌트가 리렌더링될 때는 실행되지 않아요.

// src/App.js
import TodoList from './TodoList.js';

export default function App() {
  return <TodoList username="Taylor" />;
}
// src/TodoList.js (active)
import { useReducer } from 'react';

function createInitialState(username) {
  const initialTodos = [];
  for (let i = 0; i < 50; i++) {
    initialTodos.push({
      id: i,
      text: username + "'s task #" + (i + 1)
    });
  }
  return {
    draft: '',
    todos: initialTodos,
  };
}

function reducer(state, action) {
  switch (action.type) {
    case 'changed_draft': {
      return {
        draft: action.nextDraft,
        todos: state.todos,
      };
    };
    case 'added_todo': {
      return {
        draft: '',
        todos: [{
          id: state.todos.length,
          text: state.draft
        }, ...state.todos]
      }
    }
  }
  throw Error('Unknown action: ' + action.type);
}

export default function TodoList({ username }) {
  const [state, dispatch] = useReducer(
    reducer,
    username,
    createInitialState
  );
  return (
    <>
      <input
        value={state.draft}
        onChange={e => {
          dispatch({
            type: 'changed_draft',
            nextDraft: e.target.value
          })
        }}
      />
      <button onClick={() => {
        dispatch({ type: 'added_todo' });
      }}>Add</button>
      <ul>
        {state.todos.map(item => (
          <li key={item.id}>
            {item.text}
          </li>
        ))}
      </ul>
    </>
  );
}

초기 state를 직접 전달하기 {/passing-the-initial-state-directly/}

이 예제는 초기화 함수를 전달하지 않으므로, input에 타이핑하는 것처럼 매 렌더링마다 createInitialState 함수가 실행돼요. 동작에서 관찰 가능한 차이는 없지만, 이 코드는 덜 효율적이에요.

// src/App.js
import TodoList from './TodoList.js';

export default function App() {
  return <TodoList username="Taylor" />;
}
// src/TodoList.js (active)
import { useReducer } from 'react';

function createInitialState(username) {
  const initialTodos = [];
  for (let i = 0; i < 50; i++) {
    initialTodos.push({
      id: i,
      text: username + "'s task #" + (i + 1)
    });
  }
  return {
    draft: '',
    todos: initialTodos,
  };
}

function reducer(state, action) {
  switch (action.type) {
    case 'changed_draft': {
      return {
        draft: action.nextDraft,
        todos: state.todos,
      };
    };
    case 'added_todo': {
      return {
        draft: '',
        todos: [{
          id: state.todos.length,
          text: state.draft
        }, ...state.todos]
      }
    }
  }
  throw Error('Unknown action: ' + action.type);
}

export default function TodoList({ username }) {
  const [state, dispatch] = useReducer(
    reducer,
    createInitialState(username)
  );
  return (
    <>
      <input
        value={state.draft}
        onChange={e => {
          dispatch({
            type: 'changed_draft',
            nextDraft: e.target.value
          })
        }}
      />
      <button onClick={() => {
        dispatch({ type: 'added_todo' });
      }}>Add</button>
      <ul>
        {state.todos.map(item => (
          <li key={item.id}>
            {item.text}
          </li>
        ))}
      </ul>
    </>
  );
}

문제 해결 (Troubleshooting) {/troubleshooting/}

action을 dispatch했는데 로그에 이전 state 값이 나와요 {/ive-dispatched-an-action-but-logging-gives-me-the-old-state-value/}

dispatch 함수를 호출해도 실행 중인 코드에서는 state가 변경되지 않아요:

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

  dispatch({ type: 'incremented_age' }); // 43으로 리렌더링 요청
  console.log(state.age);  // 여전히 42!

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

이건 state가 스냅샷처럼 동작하기 때문이에요. state를 업데이트하면 새 state 값으로 다른 렌더링을 요청하지만, 이미 실행 중인 이벤트 핸들러의 state JavaScript 변수에는 영향을 주지 않아요.

다음 state 값을 추측해야 한다면, 리듀서를 직접 호출해서 수동으로 계산할 수 있어요:

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

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

action을 dispatch했는데 화면이 업데이트되지 않아요 {/ive-dispatched-an-action-but-the-screen-doesnt-update/}

React는 Object.is 비교로 판단했을 때 다음 state가 이전 state와 동일하면 업데이트를 무시해요. 이건 보통 state의 객체나 배열을 직접 변경할 때 발생해요:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // 🚩 잘못됨: 기존 객체를 변경함
      state.age++;
      return state;
    }
    case 'changed_name': {
      // 🚩 잘못됨: 기존 객체를 변경함
      state.name = action.nextName;
      return state;
    }
    // ...
  }
}

기존 state 객체를 변경하고 반환했기 때문에, React가 업데이트를 무시한 거예요. 이걸 고치려면, 변경(mutating)하는 대신 항상 state에서 객체 업데이트하기state에서 배열 업데이트하기를 해야 해요:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      // ✅ 올바름: 새 객체 생성
      return {
        ...state,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      // ✅ 올바름: 새 객체 생성
      return {
        ...state,
        name: action.nextName
      };
    }
    // ...
  }
}

dispatch 후 리듀서 state의 일부가 undefined가 돼요 {/a-part-of-my-reducer-state-becomes-undefined-after-dispatching/}

새 state를 반환할 때 모든 case 분기에서 기존의 모든 필드를 복사하고 있는지 확인하세요:

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        ...state, // 이걸 잊지 마세요!
        age: state.age + 1
      };
    }
    // ...

위의 ...state 없이는, 반환된 다음 state에 age 필드만 포함되고 다른 건 아무것도 없을 거예요.


dispatch 후 리듀서 state 전체가 undefined가 돼요 {/my-entire-reducer-state-becomes-undefined-after-dispatching/}

state가 예기치 않게 undefined가 된다면, case 중 하나에서 state를 return하는 걸 잊었거나, action 타입이 어떤 case 문과도 매치되지 않는 것일 가능성이 높아요. 원인을 찾으려면, switch 밖에서 에러를 throw하세요:

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

TypeScript 같은 정적 타입 검사기를 사용해서 이런 실수를 잡을 수도 있어요.


"Too many re-renders" 에러가 나요 {/im-getting-an-error-too-many-re-renders/}

Too many re-renders. React limits the number of renders to prevent an infinite loop.라는 에러를 볼 수 있어요. 일반적으로 이건 렌더링 중에 무조건적으로 action을 dispatch하고 있어서, 컴포넌트가 루프에 빠지는 걸 의미해요: 렌더링, dispatch (렌더링을 유발), 렌더링, dispatch (렌더링을 유발), 이런 식으로요. 매우 흔하게, 이벤트 핸들러를 지정하는 데 실수가 있어서 발생해요:

// 🚩 잘못됨: 렌더링 중에 핸들러를 호출함
return <button onClick={handleClick()}>Click me</button>

// ✅ 올바름: 이벤트 핸들러를 전달함
return <button onClick={handleClick}>Click me</button>

// ✅ 올바름: 인라인 함수를 전달함
return <button onClick={(e) => handleClick(e)}>Click me</button>

이 에러의 원인을 찾을 수 없다면, 콘솔에서 에러 옆의 화살표를 클릭해서 JavaScript 스택을 살펴보면 에러를 유발하는 특정 dispatch 함수 호출을 찾을 수 있어요.


리듀서나 초기화 함수가 두 번 실행돼요 {/my-reducer-or-initializer-function-runs-twice/}

Strict Mode에서는 React가 리듀서와 초기화 함수를 두 번 호출해요. 이게 코드를 깨뜨리면 안 돼요.

이건 개발 환경에서만 일어나는 동작이고, 컴포넌트를 순수하게 유지하는 데 도움을 줘요. React는 두 번의 호출 중 하나의 결과를 사용하고, 다른 호출의 결과는 무시해요. 컴포넌트, 초기화 함수, 리듀서 함수가 순수하다면 이게 로직에 영향을 주지 않아야 해요. 하지만 실수로 불순한(impure) 경우에는 실수를 알아차리는 데 도움이 돼요.

예를 들어, 이 불순한 리듀서 함수는 state의 배열을 직접 변경해요:

function reducer(state, action) {
  switch (action.type) {
    case 'added_todo': {
      // 🚩 실수: state를 변경함
      state.todos.push({ id: nextId++, text: action.text });
      return state;
    }
    // ...
  }
}

React가 리듀서 함수를 두 번 호출하기 때문에, todo가 두 번 추가된 걸 볼 수 있고, 실수가 있다는 걸 알 수 있어요. 이 예제에서는 배열을 변경하는 대신 교체해서 실수를 고칠 수 있어요:

function reducer(state, action) {
  switch (action.type) {
    case 'added_todo': {
      // ✅ 올바름: 새 state로 교체
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: nextId++, text: action.text }
        ]
      };
    }
    // ...
  }
}

이제 이 리듀서 함수가 순수하므로, 한 번 더 호출해도 동작에 차이가 없어요. 이것이 React가 두 번 호출해서 실수를 찾는 데 도움을 주는 이유예요. 오직 컴포넌트, 초기화 함수, 리듀서 함수만 순수해야 해요. 이벤트 핸들러는 순수할 필요가 없으므로, React는 이벤트 핸들러를 두 번 호출하지 않아요.

더 자세한 내용은 컴포넌트를 순수하게 유지하기를 읽어보세요.


사이트맵

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글