Extracting State Logic into a Reducer

김동현·2026년 3월 15일

title: 상태 로직을 Reducer로 추출하기

여러 이벤트 핸들러에 상태 업데이트 로직이 여기저기 흩어져 있는 컴포넌트는 금방 코드가 길어지고 복잡해지기 마련이죠. 이런 경우에는 컴포넌트 외부에 상태 업데이트 로직을 하나로 모아주는 단일 함수를 사용할 수 있는데, 우리는 이것을 Reducer(리듀서) 라고 부릅니다.

강사로서 조금 덧붙이자면, 프론트엔드 개발을 하다 보면 컴포넌트가 커지는 것은 순식간이에요. 나중에 실무에서 복잡한 애플리케이션을 다루거나 Next.js 같은 프레임워크 위에서 다양한 상태를 관리할 때, 이렇게 로직을 분리하고 구조화하는 패턴을 알아두면 유지보수성이 뛰어난 코드를 작성하는 데 아주 큰 무기가 될 거예요!

  • 리듀서(reducer) 함수가 무엇인지
  • useStateuseReducer로 리팩토링하는 방법
  • 언제 리듀서를 사용해야 하는지
  • 리듀서를 잘 작성하는 방법

리듀서로 상태 로직 통합하기 {/consolidate-state-logic-with-a-reducer/}

컴포넌트가 점점 복잡해지면, 컴포넌트의 상태가 어떤 방식들로 업데이트되는지 한눈에 파악하기가 어려워집니다. 예를 들어, 아래의 TaskApp 컴포넌트는 할 일 목록인 tasks 배열을 상태로 가지고 있고, 할 일을 추가하고, 삭제하고, 수정하는 세 가지 다른 이벤트 핸들러를 사용하고 있어요. 코드를 한번 찬찬히 살펴볼까요?

// src/App.js
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},
];
// 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;
}

각각의 이벤트 핸들러는 상태를 업데이트하기 위해 setTasks를 호출하고 있죠. 이 컴포넌트가 커질수록, 컴포넌트 곳곳에 흩뿌려진 상태 로직의 양도 함께 늘어납니다. 이런 복잡성을 줄이고 모든 로직을 쉽게 접근할 수 있는 한 곳에 모아두기 위해, 여러분은 이 상태 로직을 컴포넌트 외부에 있는 단일 함수로 옮길 수 있어요. 우리는 이것을 "리듀서(reducer)"라고 부릅니다.

리듀서는 상태를 다루는 또 다른 방법이에요. 다음 세 가지 단계를 거치면 useState에서 useReducer로 마이그레이션할 수 있습니다.

  1. 상태를 직접 설정하는 것에서 액션을 디스패치하는 것으로 변경(Move) 하기.
  2. 리듀서 함수 작성(Write) 하기.
  3. 컴포넌트에서 리듀서 사용(Use) 하기.

1단계: 상태 설정에서 액션 디스패치로 변경하기 {/step-1-move-from-setting-state-to-dispatching-actions/}

현재 여러분의 이벤트 핸들러들은 상태를 직접 설정함으로써 무엇을 해야 할지(what to do) 를 명시하고 있어요.

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

여기서 상태를 설정하는 로직을 모두 지워보세요. 그러면 세 개의 이벤트 핸들러만 남게 됩니다.

  • 사용자가 "Add"를 누르면 handleAddTask(text)가 호출됩니다.
  • 사용자가 할 일을 토글하거나 "Save"를 누르면 handleChangeTask(task)가 호출됩니다.
  • 사용자가 "Delete"를 누르면 handleDeleteTask(taskId)가 호출됩니다.

리듀서로 상태를 관리하는 것은 상태를 직접 설정하는 것과는 조금 다릅니다. React에게 상태를 설정해서 "무엇을 할지" 지시하는 대신, 이벤트 핸들러에서 "액션(actions)"을 디스패치(dispatch, 발송)함으로써 "사용자가 방금 무엇을 했는지"를 명시하는 거예요. (상태 업데이트 로직은 이제 다른 곳에 살게 될 겁니다!) 즉, 이벤트 핸들러를 통해 tasks를 직접 "설정"하는 대신, "할 일을 추가/변경/삭제함"이라는 액션을 디스패치하는 거죠. 이 방식이 사용자의 의도를 훨씬 더 잘 설명해 줍니다.

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" 객체:
    {
      type: 'deleted',
      id: taskId,
    }
  );
}

이 액션은 아주 평범한 JavaScript 객체예요. 이 안에 무엇을 넣을지는 전적으로 여러분이 결정하지만, 일반적으로는 무슨 일이 일어났는지 에 대한 최소한의 정보를 담는 것이 좋습니다. (dispatch 함수 자체는 다음 단계에서 추가해 볼 거예요.)

액션 객체는 어떤 형태든 가질 수 있어요.

하지만 관례상, 무슨 일이 일어났는지 설명하는 문자열 type을 부여하고, 추가적인 정보는 다른 필드에 담아서 전달하는 것이 일반적입니다. type은 해당 컴포넌트에 한정되므로, 이 예제에서는 'added''added_task' 둘 다 괜찮아요. 어떤 일이 일어났는지 명확하게 말해주는 이름을 선택해 보세요! 프론트엔드 개발 시 이 명명 규칙을 잘 지키면 나중에 팀원들과 협업할 때나 디버깅할 때 훨씬 수월해진답니다.

dispatch({
  // 컴포넌트에 특정된 이름
  type: 'what_happened',
  // 다른 필드들은 여기에 추가합니다
});

2단계: 리듀서 함수 작성하기 {/step-2-write-a-reducer-function/}

리듀서 함수는 바로 여러분의 상태 로직이 들어갈 곳입니다. 이 함수는 현재 상태(current state)와 액션 객체(action object)라는 두 개의 인자를 받아서, 다음 상태(next state)를 반환해요.

function yourReducer(state, action) {
  // React가 설정할 다음 상태를 반환합니다.
}

React는 리듀서에서 반환한 값으로 상태를 설정하게 됩니다.

이 예제에서 이벤트 핸들러에 있던 상태 설정 로직을 리듀서 함수로 옮기려면 다음 과정이 필요해요.

  1. 첫 번째 인자로 현재 상태(tasks)를 선언합니다.
  2. 두 번째 인자로 action 객체를 선언합니다.
  3. 리듀서에서 다음(next) 상태를 반환합니다 (그러면 React가 이 상태로 업데이트해 줄 거예요).

아래는 모든 상태 설정 로직을 리듀서 함수로 마이그레이션한 코드입니다.

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

리듀서 함수는 상태(tasks)를 인자로 받기 때문에, 컴포넌트 외부에서 선언할 수 있습니다. 이렇게 하면 코드의 들여쓰기 단계가 줄어들어서 코드를 읽기가 훨씬 편해지죠.

위의 코드는 if/else 문을 사용하고 있지만, 리듀서 안에서는 switch 문(switch statements)을 사용하는 것이 관례랍니다. 결과는 동일하지만, 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로 "넘어가서(fall through)" 원치 않는 실수가 발생할 수 있으니 주의하세요!

아직 switch 문이 익숙하지 않으시다면, if/else 문을 계속 사용하셔도 전혀 문제없습니다.

왜 이름이 리듀서(Reducer)일까요? {/why-are-reducers-called-this-way/}

리듀서는 컴포넌트 내부의 코드 양을 "줄여주기(reduce)" 때문에 리듀서인 것도 같지만, 사실 이 이름은 배열에서 수행할 수 있는 reduce() 연산에서 따온 거예요.

reduce() 연산은 배열을 가져와서 여러 값들을 하나의 값으로 "누적(accumulate)"할 수 있게 해줍니다.

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

reduce에 전달하는 함수를 "리듀서(reducer)"라고 불러요. 이 함수는 지금까지의 결과(result so far)현재 항목(current item) 을 받아서 다음 결과(next result) 를 반환하죠. React의 리듀서도 똑같은 아이디어의 연장선입니다. 지금까지의 상태(state so far)액션(action) 을 받아서 다음 상태(next state) 를 반환하니까요. 이런 식으로 리듀서는 시간의 흐름에 따른 액션들을 모아서 하나의 상태로 누적시킵니다.

심지어 initialStateactions 배열이 있다면, 거기에 여러분의 리듀서 함수를 전달해서 최종 상태를 계산하는 데 reduce() 메서드를 직접 사용해 볼 수도 있어요. 한 번 확인해 볼까요?

// src/index.js
import tasksReducer from './tasksReducer.js';

let initialState = [];
let actions = [
  {type: 'added', id: 1, text: 'Visit Kafka Museum'},
  {type: 'added', id: 2, text: 'Watch a puppet show'},
  {type: 'deleted', id: 1},
  {type: 'added', id: 3, text: 'Lennon Wall pic'},
];

let finalState = actions.reduce(tasksReducer, initialState);

const output = document.getElementById('output');
output.textContent = JSON.stringify(finalState, null, 2);
// src/tasksReducer.js
export default 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);
    }
  }
}
<pre id="output"></pre>

직접 이렇게 코드를 작성할 일은 아마 없겠지만, 이게 바로 React가 내부적으로 하는 일과 아주 비슷하답니다!

3단계: 컴포넌트에서 리듀서 사용하기 {/step-3-use-the-reducer-from-your-component/}

마지막으로, 작성한 tasksReducer를 컴포넌트에 연결해 주어야 합니다. React에서 useReducer Hook을 임포트해 볼까요?

import { useReducer } from 'react';

그런 다음 useState 부분을:

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

아래와 같이 useReducer로 교체합니다.

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

useReducer Hook은 useState와 아주 비슷해요. 둘 다 초기 상태를 전달해야 하고, 상태 값과 그 상태를 설정하는 방법(이 경우에는 dispatch 함수)을 반환하니까요. 하지만 조금 다른 점이 있습니다.

useReducer Hook은 두 개의 인자를 받습니다.

  1. 리듀서 함수 (reducer function)
  2. 초기 상태 (initial state)

그리고 다음 두 가지를 반환해요.

  1. 상태를 담고 있는 값 (stateful value)
  2. 디스패치 함수 (사용자의 액션을 리듀서로 "발송"하는 역할)

자, 이제 모든 연결이 끝났습니다! 아래 코드에서는 리듀서가 컴포넌트 파일의 맨 아래쪽에 선언되어 있어요.

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

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

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

원하신다면 리듀서를 아예 다른 파일로 분리할 수도 있어요. 코드를 모듈화하는 좋은 습관이죠.

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

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/tasksReducer.js
export default 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);
    }
  }
}
// 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;
}

이렇게 관심사를 분리하면 컴포넌트 로직을 훨씬 쉽게 읽을 수 있어요. 이제 이벤트 핸들러는 오직 액션을 디스패치해서 무슨 일이 일어났는지 만 명시하고, 리듀서 함수는 그 액션에 응답해서 상태를 어떻게 업데이트할지 만 결정하게 됩니다. 역할 분담이 확실해졌죠?

useStateuseReducer 비교하기 {/comparing-usestate-and-usereducer/}

리듀서라고 해서 무조건 완벽한 건 아니에요. 장단점이 존재합니다! 이 두 가지를 비교해 보는 시간을 가져볼게요.

  • 코드 크기 (Code size): 일반적으로 useState를 사용하면 처음에 작성할 코드가 더 적습니다. 반면 useReducer를 사용하면 리듀서 함수도 작성해야 하고 액션도 디스패치해야 하죠. 하지만 여러 이벤트 핸들러가 비슷한 방식으로 상태를 수정하는 경우에는, 오히려 useReducer가 전체 코드 양을 줄여주는 마법을 발휘할 수 있어요.
  • 가독성 (Readability): 상태 업데이트 로직이 단순할 때는 useState가 읽기 아주 편합니다. 하지만 로직이 복잡해지면 컴포넌트의 코드가 너무 비대해져서 한눈에 파악하기 힘들어지죠. 이럴 때 useReducer를 사용하면 이벤트 핸들러의 무슨 일이 일어났는지(what happened) 와 업데이트 로직의 어떻게(how) 를 깔끔하게 분리할 수 있어서 가독성이 크게 올라갑니다.
  • 디버깅 (Debugging): useState를 사용하다가 버그가 발생하면, 어디서 상태가 잘못 설정되었는지, 그리고 그랬는지 찾아내기가 까다로울 수 있어요. 반면 useReducer를 사용하면 리듀서 안에 console.log 하나만 추가해도 모든 상태 업데이트 내역과 그 업데이트가 일어난 이유(어떤 action 때문인지)를 명확하게 볼 수 있죠. 각 action이 올바르다면, 버그는 리듀서 로직 자체에 있다는 걸 바로 알 수 있습니다. 물론 useState보다 단계별로 거쳐야 할 코드가 조금 더 많다는 점은 감안해야 해요.
  • 테스트 (Testing): 리듀서는 컴포넌트와는 독립적인 순수 함수(pure function)입니다. 이 말은 즉, 리듀서만 따로 빼서 독립적으로 테스트(export and test)할 수 있다는 뜻이에요. 프론트엔드 실무에서는 컴포넌트를 더 현실적인 환경에서 테스트하는 것이 일반적이지만, 상태 업데이트 로직이 아주 복잡할 때는 특정 초기 상태와 특정 액션을 넣었을 때 리듀서가 기대하는 상태를 반환하는지 검증하는 것이 큰 도움이 된답니다.
  • 개인적 취향 (Personal preference): 리듀서를 좋아하는 사람도 있고, 별로 좋아하지 않는 사람도 있어요. 전혀 문제없습니다! 개인의 취향 문제니까요. 언제든지 useStateuseReducer 사이를 왔다 갔다 하며 변환할 수 있어요. 결국 둘은 동일한 일을 하는 거니까요!

만약 어떤 컴포넌트에서 잘못된 상태 업데이트로 인해 버그가 자주 발생하고, 코드에 조금 더 확실한 구조를 잡고 싶다면 리듀서를 사용하는 것을 강력히 추천합니다. 그렇다고 모든 것에 리듀서를 사용할 필요는 없어요. 상황에 맞게 자유롭게 섞어서 써보세요! 심지어 같은 컴포넌트 안에서 useStateuseReducer를 동시에 사용하는 것도 얼마든지 가능합니다.

리듀서 잘 작성하기 {/writing-reducers-well/}

리듀서를 작성할 때는 다음 두 가지 팁을 꼭 기억해 두세요.

  • 리듀서는 반드시 순수(pure)해야 합니다. 상태 업데이트 함수(state updater functions)와 마찬가지로 리듀서도 렌더링 중에 실행됩니다! (액션들은 다음 렌더링 때까지 큐에 쌓이게 됩니다.) 이는 리듀서가 반드시 순수해야 한다는 것을 의미해요. 즉, 같은 입력이 주어지면 언제나 똑같은 출력을 반환해야 합니다. 리듀서 안에서는 네트워크 요청을 보내거나, 타임아웃을 예약하거나, 사이드 이펙트(컴포넌트 외부에 영향을 미치는 작업)를 수행해서는 안 됩니다. 객체나 배열을 업데이트할 때는 원본을 변경(mutation)하지 말고 객체(objects)배열(arrays) 업데이트 규칙을 따라야 해요.
  • 각 액션은 데이터에 여러 변경을 일으키더라도, 단일 사용자 상호작용(single user interaction)을 설명해야 합니다. 예를 들어, 사용자가 5개의 필드를 가진 폼에서 "Reset" 버튼을 눌렀다고 해볼까요? 이때 5개의 개별적인 set_field 액션을 디스패치하는 것보다는, 하나의 reset_form 액션을 디스패치하는 것이 훨씬 더 합리적입니다. 리듀서에서 모든 액션을 로깅해 본다면, 그 로그만 보고도 사용자가 어떤 순서로 상호작용했고 어떻게 응답했는지 명확하게 재구성할 수 있어야 해요. 이게 디버깅할 때 얼마나 큰 도움이 되는지 경험해 보시면 깜짝 놀라실 거예요!

Immer를 사용하여 간결한 리듀서 작성하기 {/writing-concise-reducers-with-immer/}

일반 상태에서 객체 업데이트하기배열 업데이트하기를 배울 때 보셨겠지만, Immer 라이브러리를 사용하면 리듀서를 훨씬 더 간결하게 작성할 수 있어요. 여기서 소개할 useImmerReducer는 리듀서 안에서 push를 쓰거나 arr[i] = 처럼 값을 직접 할당해서 상태를 변경(mutate)할 수 있게 해줍니다.

// 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;
}
// package.json
{
  "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"
  }
}

리듀서는 순수해야 하기 때문에 상태를 직접 변경(mutate)하면 안 된다고 배웠었죠. 하지만 Immer는 안전하게 변경할 수 있는 특별한 draft 객체를 제공해 줍니다. Immer 내부에서는 여러분이 draft에 가한 변경 사항을 바탕으로 상태의 복사본을 알아서 만들어 주거든요. 이것이 바로 useImmerReducer가 관리하는 리듀서가 첫 번째 인자(draft)를 직접 변경할 수 있고, 별도의 상태를 반환(return)하지 않아도 되는 이유입니다.

  • useState에서 useReducer로 변환하려면:
    1. 이벤트 핸들러에서 액션을 디스패치합니다.
    2. 주어진 상태와 액션에 대해 다음 상태를 반환하는 리듀서 함수를 작성합니다.
    3. useStateuseReducer로 교체합니다.
  • 리듀서를 사용하면 코드를 조금 더 작성해야 하지만, 디버깅과 테스트에 큰 도움을 줍니다.
  • 리듀서는 반드시 순수해야 합니다.
  • 각 액션은 단일 사용자 상호작용을 설명해야 합니다.
  • 상태를 직접 변경(mutating)하는 스타일로 리듀서를 작성하고 싶다면 Immer를 사용해 보세요.

이벤트 핸들러에서 액션 디스패치하기 {/dispatch-actions-from-event-handlers/}

현재 ContactList.jsChat.js의 이벤트 핸들러에는 // TODO 주석이 달려 있습니다. 이 때문에 인풋에 글자를 타이핑해도 작동하지 않고, 버튼을 클릭해도 선택된 수신자가 변경되지 않아요.

이 두 개의 // TODO 부분을, 해당하는 액션을 dispatch하는 코드로 교체해 보세요. 액션의 예상 형태나 type을 확인하려면 messengerReducer.js 파일에 있는 리듀서를 참고하면 됩니다. 리듀서는 이미 작성되어 있으니 수정할 필요가 없고, ContactList.jsChat.js에서 액션을 디스패치하기만 하면 끝이에요!

dispatch 함수는 props로 전달되었기 때문에 두 컴포넌트 모두에서 이미 사용할 수 있는 상태입니다. 따라서 여러분은 해당 액션 객체를 담아 dispatch를 호출하기만 하면 돼요.

액션 객체의 형태를 확인하려면 리듀서 코드를 보면서 어떤 action 필드들을 기대하고 있는지 살펴보세요. 예를 들어, 리듀서 안의 changed_selection case는 이렇게 생겼습니다.

case 'changed_selection': {
  return {
    ...state,
    selectedId: action.contactId
  };
}

이 코드를 보면 여러분의 액션 객체는 type: 'changed_selection'을 가지고 있어야 한다는 뜻이에요. 또한 action.contactId가 사용되고 있는 것도 보이니, 액션에 contactId 속성도 함께 포함해 주어야 합니다.

// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  message: 'Hello',
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
        message: '',
      };
    }
    case 'edited_message': {
      return {
        ...state,
        message: action.message,
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                // TODO: changed_selection 디스패치하기
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          // TODO: edited_message 디스패치하기
          // (입력값은 e.target.value 에서 읽어오세요)
        }}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

리듀서 코드로부터 액션이 대략 이런 모습이어야 한다는 걸 유추할 수 있어요.

// 사용자가 "Alice"를 눌렀을 때
dispatch({
  type: 'changed_selection',
  contactId: 1,
});

// 사용자가 "Hello!"를 타이핑했을 때
dispatch({
  type: 'edited_message',
  message: 'Hello!',
});

해당하는 메시지들을 올바르게 디스패치하도록 업데이트한 예제는 다음과 같습니다.

// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  message: 'Hello',
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
        message: '',
      };
    }
    case 'edited_message': {
      return {
        ...state,
        message: action.message,
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

메시지를 보낼 때 입력란 비우기 {/clear-the-input-on-sending-a-message/}

현재는 "Send(전송)" 버튼을 눌러도 아무 일도 일어나지 않아요. "Send" 버튼에 다음과 같이 동작하는 이벤트 핸들러를 추가해 보세요.

  1. 수신자의 이메일과 메시지 내용을 보여주는 alert 창을 띄웁니다.
  2. 메시지 입력란을 비웁니다.
// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  message: 'Hello',
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
        message: '',
      };
    }
    case 'edited_message': {
      return {
        ...state,
        message: action.message,
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

"Send" 버튼 이벤트 핸들러에서 이를 처리하는 방법은 두 가지 정도가 있어요. 한 가지 접근 방식은 alert 창을 먼저 띄우고 난 뒤에 빈 message를 가진 edited_message 액션을 디스패치하는 겁니다.

// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  message: 'Hello',
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
        message: '',
      };
    }
    case 'edited_message': {
      return {
        ...state,
        message: action.message,
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button
        onClick={() => {
          alert(`Sending "${message}" to ${contact.email}`);
          dispatch({
            type: 'edited_message',
            message: '',
          });
        }}>
        Send to {contact.email}
      </button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

이렇게 해도 아주 잘 작동하고, "Send"를 누르면 입력란이 비워집니다.

하지만 사용자의 관점 에서 보면, 메시지를 '보내는(sending)' 행동은 단순히 필드 내용을 '수정하는(editing)' 행동과는 전혀 다른 액션이에요. 이 점을 더 잘 반영하기 위해서, 아예 sent_message라는 새로운 액션을 만들고 리듀서에서 이를 따로 처리하는 방식이 더 좋습니다.

// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  message: 'Hello',
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
        message: '',
      };
    }
    case 'edited_message': {
      return {
        ...state,
        message: action.message,
      };
    }
    case 'sent_message': {
      return {
        ...state,
        message: '',
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button
        onClick={() => {
          alert(`Sending "${message}" to ${contact.email}`);
          dispatch({
            type: 'sent_message',
          });
        }}>
        Send to {contact.email}
      </button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

결과적으로 나타나는 동작은 똑같습니다. 하지만 액션 타입(type)은 "상태를 어떻게 바꾸고 싶은지"보다는 "사용자가 무엇을 했는지"를 묘사하는 것이 이상적이라는 사실을 꼭 명심해 주세요. 이렇게 설계해야 나중에 새로운 기능을 추가할 때 확장이 훨씬 수월해집니다.

어떤 해결책을 선택하시든, 절대로 alert 창을 띄우는 코드를 리듀서 안에 넣어서는 안 됩니다. 명심하세요, 리듀서는 오직 다음 상태를 계산하기만 하는 순수 함수(pure function)여야 해요. 사용자에게 메시지를 보여주는 것을 포함해서 어떤 무언가를 "행동(do)"해서는 안 됩니다. 그런 일들은 반드시 이벤트 핸들러 안에서 일어나야 해요! (이런 종류의 실수를 잡아내기 쉽게 도와주려고, React는 엄격 모드(Strict Mode)에서 리듀서를 여러 번 호출해 봅니다. 그래서 만약 리듀서 안에 alert를 넣으면 경고 창이 두 번 연속으로 뜨게 될 거예요.)

탭을 전환할 때 입력값 유지하기 {/restore-input-values-when-switching-between-tabs/}

이 예제에서는 다른 수신자로 탭을 전환할 때마다 텍스트 입력란이 항상 비워집니다.

case 'changed_selection': {
  return {
    ...state,
    selectedId: action.contactId,
    message: '' // 입력란 비우기
  };

그 이유는 하나의 메시지 임시 저장(draft) 내용을 여러 수신자가 공유하는 것을 원치 않기 때문이죠. 하지만, 앱이 각 연락처별로 따로 메시지 내용을 "기억"해 두었다가, 해당 연락처로 다시 전환할 때 복원해 준다면 훨씬 사용자 경험이 좋을 거예요.

여러분의 과제는 각 연락처별로 별도의 메시지 내용을 기억할 수 있도록 상태 구조 자체를 변경 하는 겁니다. 그러려면 리듀서, 초기 상태(initial state), 그리고 컴포넌트에 몇 가지 수정이 필요할 거예요.

상태 구조를 이렇게 잡아볼 수 있어요.

export const initialState = {
  selectedId: 0,
  messages: {
    0: 'Hello, Taylor', // contactId = 0 을 위한 임시 저장
    1: 'Hello, Alice', // contactId = 1 을 위한 임시 저장
  },
};

그리고 [key]: value 문법인 계산된 프로퍼티 이름(computed property) 문법을 사용하면 messages 객체를 쉽게 업데이트할 수 있답니다.

{
  ...state.messages,
  [id]: message
}
// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.message;
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  message: 'Hello',
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
        message: '',
      };
    }
    case 'edited_message': {
      return {
        ...state,
        message: action.message,
      };
    }
    case 'sent_message': {
      return {
        ...state,
        message: '',
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button
        onClick={() => {
          alert(`Sending "${message}" to ${contact.email}`);
          dispatch({
            type: 'sent_message',
          });
        }}>
        Send to {contact.email}
      </button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

연락처별로 각각 메시지 내용을 저장하고 업데이트하도록 리듀서를 수정해야 합니다.

// 입력란이 수정되었을 때
case 'edited_message': {
  return {
    // 선택 항목과 같은 다른 상태는 유지합니다
    ...state,
    messages: {
      // 다른 연락처들의 메시지도 유지합니다
      ...state.messages,
      // 하지만 현재 선택된 연락처의 메시지만 새롭게 바꿉니다
      [state.selectedId]: action.message
    }
  };
}

그리고 Messenger 컴포넌트에서도 현재 선택된 연락처에 맞는 메시지를 읽어오도록 바꿔주어야 해요.

const message = state.messages[state.selectedId];

이걸 반영한 완성된 솔루션은 다음과 같습니다.

// src/App.js
import { useReducer } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.messages[state.selectedId];
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  messages: {
    0: 'Hello, Taylor',
    1: 'Hello, Alice',
    2: 'Hello, Bob',
  },
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
      };
    }
    case 'edited_message': {
      return {
        ...state,
        messages: {
          ...state.messages,
          [state.selectedId]: action.message,
        },
      };
    }
    case 'sent_message': {
      return {
        ...state,
        messages: {
          ...state.messages,
          [state.selectedId]: '',
        },
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button
        onClick={() => {
          alert(`Sending "${message}" to ${contact.email}`);
          dispatch({
            type: 'sent_message',
          });
        }}>
        Send to {contact.email}
      </button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

주목할 점은, 이 새로운 동작을 구현하기 위해 이벤트 핸들러 중 어느 것도 손댈 필요가 없었다는 거예요. 리듀서가 없었다면 상태를 업데이트하는 모든 이벤트 핸들러 코드를 찾아다니며 직접 바꿔줘야 했을 텐데 말이죠. 이게 바로 리듀서의 강력함입니다!

useReducer 직접 구현해 보기 {/implement-usereducer-from-scratch/}

앞의 예제들에서는 React에서 useReducer Hook을 불러와서(import) 썼었죠. 이번에는 useReducer Hook 자체 를 직접 한번 구현해 볼까요? 여기 시작을 도와줄 기초 코드가 준비되어 있어요. 아마 10줄 이내의 코드면 충분히 완성할 수 있을 겁니다.

작성한 코드를 테스트해 보려면, 입력란에 타이핑을 해보거나 연락처를 선택해 보세요.

구현해야 할 전체적인 스케치는 다음과 같습니다.

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

  function dispatch(action) {
    // ???
  }

  return [state, dispatch];
}

잘 생각해 보세요. 리듀서 함수는 현재 상태(current state)와 액션 객체(action object)라는 두 개의 인자를 받아서 다음 상태(next state)를 반환하는 함수였죠? 그렇다면 여러분이 작성할 dispatch 안에서는 이 리듀서 함수를 어떻게 활용해야 할까요?

// src/App.js
import { useReducer } from './MyReact.js';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.messages[state.selectedId];
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  messages: {
    0: 'Hello, Taylor',
    1: 'Hello, Alice',
    2: 'Hello, Bob',
  },
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
      };
    }
    case 'edited_message': {
      return {
        ...state,
        messages: {
          ...state.messages,
          [state.selectedId]: action.message,
        },
      };
    }
    case 'sent_message': {
      return {
        ...state,
        messages: {
          ...state.messages,
          [state.selectedId]: '',
        },
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/MyReact.js
import { useState } from 'react';

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

  // ???

  return [state, dispatch];
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button
        onClick={() => {
          alert(`Sending "${message}" to ${contact.email}`);
          dispatch({
            type: 'sent_message',
          });
        }}>
        Send to {contact.email}
      </button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

액션을 디스패치한다는 것은 곧, 현재 상태와 해당 액션을 넣어 리듀서를 호출하고, 그 결과값을 다음 상태(next state)로 저장한다는 뜻입니다. 코드로 작성하면 다음과 같아요!

// src/App.js
import { useReducer } from './MyReact.js';
import Chat from './Chat.js';
import ContactList from './ContactList.js';
import { initialState, messengerReducer } from './messengerReducer';

export default function Messenger() {
  const [state, dispatch] = useReducer(messengerReducer, initialState);
  const message = state.messages[state.selectedId];
  const contact = contacts.find((c) => c.id === state.selectedId);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={state.selectedId}
        dispatch={dispatch}
      />
      <Chat
        key={contact.id}
        message={message}
        contact={contact}
        dispatch={dispatch}
      />
    </div>
  );
}

const contacts = [
  {id: 0, name: 'Taylor', email: 'taylor@mail.com'},
  {id: 1, name: 'Alice', email: 'alice@mail.com'},
  {id: 2, name: 'Bob', email: 'bob@mail.com'},
];
// src/messengerReducer.js
export const initialState = {
  selectedId: 0,
  messages: {
    0: 'Hello, Taylor',
    1: 'Hello, Alice',
    2: 'Hello, Bob',
  },
};

export function messengerReducer(state, action) {
  switch (action.type) {
    case 'changed_selection': {
      return {
        ...state,
        selectedId: action.contactId,
      };
    }
    case 'edited_message': {
      return {
        ...state,
        messages: {
          ...state.messages,
          [state.selectedId]: action.message,
        },
      };
    }
    case 'sent_message': {
      return {
        ...state,
        messages: {
          ...state.messages,
          [state.selectedId]: '',
        },
      };
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
// src/MyReact.js
import { useState } from 'react';

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

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}
// src/ContactList.js
export default function ContactList({contacts, selectedId, dispatch}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <button
              onClick={() => {
                dispatch({
                  type: 'changed_selection',
                  contactId: contact.id,
                });
              }}>
              {selectedId === contact.id ? <b>{contact.name}</b> : contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}
// src/Chat.js
import { useState } from 'react';

export default function Chat({contact, message, dispatch}) {
  return (
    <section className="chat">
      <textarea
        value={message}
        placeholder={'Chat to ' + contact.name}
        onChange={(e) => {
          dispatch({
            type: 'edited_message',
            message: e.target.value,
          });
        }}
      />
      <br />
      <button
        onClick={() => {
          alert(`Sending "${message}" to ${contact.email}`);
          dispatch({
            type: 'sent_message',
          });
        }}>
        Send to {contact.email}
      </button>
    </section>
  );
}
.chat,
.contact-list {
  float: left;
  margin-bottom: 20px;
}
ul,
li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 100px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

대부분의 경우엔 위 코드만으로도 충분하지만, React의 실제 동작에 조금 더 정확하게 맞춘 구현은 다음과 같아요.

function dispatch(action) {
  setState((s) => reducer(s, action));
}

그 이유는 업데이터 함수(updater functions)를 사용할 때와 마찬가지로, 디스패치된 액션들이 다음 렌더링 때까지 큐(queue)에 쌓이기 때문입니다.


사이트맵 (Sitemap)

모든 문서 페이지 개요 보기 (Overview of all docs pages)

오늘 학습은 여기까지입니다! 상태 관리 로직을 리듀서로 분리하는 방법이 좀 더 익숙해지셨나요? React 문서를 한글로 계속 공부하면서 헷갈리는 부분이 있다면 언제든지 또 물어보세요!

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

0개의 댓글