[React] useReducer, context API로 투두리스트 만들기

챌린지반 과제로.. 과제로 나온 Context, RTK 외에 useReducer도 다뤄보자는 의미로 투두리스트 만들기 과제가 생겼다

ㅎㅎㅎ 개인과제를 조금 욕심내서 기능구현 위주로 빨리 해보자고 목표를 잡은게 다행이었던 것 같다..

 

Reducer

: 여러곳으로 흩어져있는 state 관리 로직을 단일 함수로 통합해 관리하기위해 사용

즉, state를 다루는 방법 중 하나다!

 

useState에서 useReducer로 변경하기

1. state를 설정하는 것에서 action을 dispatch로 전달하는 것으로 바꾸기

- setState를 사용해야하는 부분에서 setState로 직접 변경하는 것이 아니라 action을 전달해 어떤 형식으로 업데이트 할지 reducer에게 전달

- action은 일반적으로 객체, state와 관련해 어떤 상황이 발생했는지 정보를 담고 있어야 함

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

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

 

2. reducer함수 작성하기

- state를 처리할 로직을 담을 함수

- 이 함수는 현재 state값, 전달받은 action 객체 두 인자를 받고 변경된 state값을 반환

function yourReducer(state, action) {
  return {action에따라 변환된 state}
}
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);
    }
  }

 

 

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

 

- 컴포넌트에서 useReducer hook을 임포트

import { useReducer } from 'react';

 

- useState를 사용할 부분에서 useReducer로 변경

- useReducer는 reducer 함수, 초기 state값을 인자로 받는다

- useReducer는 state를 담을 값, dispatch 함수를 반환한다

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

// useReducer로 변경
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

 

▼ 완성된 예제

더보기
// App.jsx
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 },
];

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

 

useState, useReducer 비교

1) 코드크기

- useReducer는 사용하기위해 작성해야하는 코드 양이 많지만, 여러 곳에서 state를 업데이트하는 경우 useState를 사용했을 때보다 코드의 양이 줄어들 수 있다.

 

2) 가독성

- 간단한 state 업데이트는 useState가 좋다

- 하지만 복잡한 구조의 state를 다루게되면 컴포넌트 내 코드의 양이 많아져 한눈에 읽기 어려워질 수 있다.

- 따라서 useReducer를 이용해 이벤트 핸들러와 state 업데이트 로직을 분리하면 도움이 될 수 있다

 

3) 디버깅

- useState는 에러가 발생했을 때 잘못된 곳을 찾기 어려울 수 있음

- useReducer를 사용하면 console.log등을 활용해 어떤 action에서 버그가 발생하는지 확인할 수 있음

- 하지만 useState보다 더 많은 단계를 살펴봐야하는 단점도 있다

 

 

reducer 작성시 주의할 점

1) reducer는 순수함수여야 함

- 입력이 같다면 결과가 항상 동일해야 함

- 외부 컴포넌트에 영향을 미치는 작업을 수행해선 안됨

- object, array를 변이없이 업데이트해야한다 (immutable)

 

2) 각  action은 데이터 안에서 여러 변경이 있어도 하나의 사용자 상호작용을 설명해야 함

- 예를들어 5개의 입력창이 있고, 그를 재설정하는 버튼을 누른 경우 하나의 입력창에 대한 state 변경을 5번 반복하는 것보다 전체 입력창을 초기화하는 동작을 하나 실행하는 것이 좋음

- 의도를 파악하기 쉽고, 디버깅에 도움됨

 

 

Reducer + Context

useReucer로 반환받은 state, reducer함수는 하위 함수에서 사용하려면 다시 props로 내려줘야 함

불필요한 props 전달을 방지하기위해 context를 사용할 수 있음

 

Reducer와 context를 결합하는 방법

1. Context 생성

- reducer를 사용하기위해 두가지 context를 생성해야 함

- 관리할 state (현재 예제에서 task 리스트), 컴포넌트에서 action을 전달할 dispatch 함수

 

App.js

더보기
// 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>Day off in Kyoto</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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

TaskContext.js

더보기
import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

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

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

 

2. State와 dispatch 함수를 context에 넣기

useReducer에서 반환된 tasks 및 dispatch를 가져와 돔 트리 전체에 전달할 수 있도록 Provider로 감싸기

import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  // ...
  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        ...
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

 

 

3. 트리 안에서 context 사용하기

위 단계까지는 task, dispatch 함수를 props로 전달해줘야하지만, context를 이용하면 전달할 필요가 없다!

export default function TaskList() {
  const tasks = useContext(TasksContext);
  // ...

 

useContext를 이용해 받아주면 된다

state는 여전히 최상위 컴포넌트에서 useReducer로 관리되고 있지만, props로 내려주지 않아도 필요한 컴포넌트에서 context를 읽어와 사용할 수 있다

 

하나의 파일로 정리하기

TaskContext.js의 경우 두개의 createContext를 이용해 두개의 context를 선언하는 것이 끝이다

그래서 reducer와 함께 하나의 파일로 관리하기도 한다

 

TaskProvider는 아래 역할을 한다

- Reducer로 state 관리

- 두 context를 하위 컴포넌트에 제공

- children을 prop으로 받음

 

import { createContext, useReducer } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(
    tasksReducer,
    initialTasks
  );

  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

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

const initialTasks = [
  { id: 0, text: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

 

추가로 컨텍스트를 사용하기위한 커스텀 훅도 TasksContext.js에서 관리해도 좋다

export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

 

 

참고자료

https://ko.react.dev/learn/extracting-state-logic-into-a-reducer

 

state 로직을 reducer로 작성하기 – React

The library for web and native user interfaces

ko.react.dev

 

https://ko.react.dev/learn/scaling-up-with-reducer-and-context

 

Reducer와 Context로 앱 확장하기 – React

The library for web and native user interfaces

ko.react.dev