Reducer와 Context로 앱 확장하기

Reducer를 사용하면 컴포넌트의 state 업데이트 로직을 통합할 수 있습니다. Context를 사용하면 다른 컴포넌트들에 정보를 전달할 수 있습니다. Reducer와 context를 함께 사용하여 복잡한 화면의 state를 관리할 수 있습니다.

학습 내용

  • reducer와 context를 결합하는 방법
  • state와 dispatch 함수를 prop으로 전달하지 않는 방법
  • context와 state 로직을 별도의 파일에서 관리하는 방법

Reducer와 context를 결합하기

Reducer의 개요의 예시에서 reducer로 state를 관리하는 방법을 알아보았습니다. 해당 예시에서 state 업데이트 로직을 모두 포함하는 reducer 함수를 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 }
];

Reducer는 이벤트 핸들러를 짧고 간결하게 유지하는 데 도움이 됩니다. 그러나 앱이 커지면 다른 어려움에 부딪힐지도 모릅니다. 현재 tasks state 및 dispatch 함수는 최상위 TaskApp 컴포넌트에서만 사용할 수 있습니다. 다른 컴포넌트가 작업 목록을 읽거나 변경하려면 현재 state와 해당 state를 변경하는 이벤트 핸들러를 명시적으로 props로 전달해야 합니다.

예를 들어, TaskApp은 tasks 리스트와 이벤트 핸들러를 TaskList에 전달합니다.

<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>

그리고 TaskList 컴포넌트에서 Task 컴포넌트로 이벤트 핸들러를 전달합니다.

<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>

지금처럼 간단한 예시에서는 잘 동작하지만, 수십 수백개의 컴포넌트를 거쳐 state나 함수를 전달하기는 쉽지 않습니다.

이것이 props를 통한 전달 대신 tasks state와 dispatch 함수를 모두 context에 넣고 싶은 이유입니다. 이렇게 하면 트리에서 TaskApp 아래에 있는 모든 컴포넌트가 “prop drilling”이라는 반복적인 작업 없이 tasks와 dispatch actions를 읽을 수 있습니다.

Reducer와 context를 결합하는 방법은 아래와 같습니다.

  1. Context를 생성한다.
  2. State과 dispatch 함수를 context에 넣는다.
  3. 트리 안에서 context를 사용한다.

1단계: Context 생성

useReducer Hook은 현재 tasks와 업데이트할 수 있는 dispatch 함수를 반환합니다.

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

트리를 통해 전달하려면, 두 개의 별개의 context를 생성해야 합니다.

  • TasksContext는 현재 tasks 리스트를 제공합니다.
  • TasksDispatchContext는 컴포넌트에서 action을 dispatch 하는 함수를 제공합니다.

두 context는 나중에 다른 파일에서 가져올 수 있도록 별도의 파일에서 내보냅니다.

import { createContext } from 'react';

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

여기서는 두 context에 모두 기본값으로 null을 전달하고 있습니다. 실제 값은 TaskApp 컴포넌트에서 제공될 것입니다.

2단계: State과 dispatch 함수를 context에 넣기

이제 TaskApp 컴포넌트에서 두 context를 모두 가져올 수 있습니다. useReducer()에서 반환된 tasksdispatch를 가져와 아래 트리 전체에 제공하세요.

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

지금은 props와 context를 모두 이용하여 정보를 전달하고 있습니다.

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.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 (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        <h1>Day off in Kyoto</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </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);
    }
  }
}

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

다음 단계에서 이제 prop을 통한 전달을 제거합니다.

3단계: 트리 안에서 context 사용하기

이제 tasks 리스트나 이벤트 핸들러를 트리 아래로 전달할 필요가 없습니다.

<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Day off in Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>

대신 필요한 컴포넌트에서는 TaskContext에서 tasks 리스트를 읽을 수 있습니다.

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

tasks 리스트를 업데이트하기 위해서 컴포넌트에서 context의 dispatch 함수를 읽고 호출할 수 있습니다.

export default function AddTask({ onAddTask }) {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Add</button>
// ...

TaskApp 컴포넌트는 어떤 이벤트 핸들러도 아래로 전달하지 않으며, TaskListTask 컴포넌트로 이벤트 핸들러를 전달하지 않습니다. 각 컴포넌트는 필요한 context를 읽습니다.

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

export default function TaskList() {
  const tasks = useContext(TasksContext);
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useContext(TasksDispatchContext);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...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 => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Delete
      </button>
    </label>
  );
}

State는 여전히 최상위 TaskApp 컴포넌트에서 useReducer로 관리되고 있습니다. 그러나 이제 context를 가져와 트리 아래의 모든 컴포넌트에서 해당 tasksdispatch를 사용할 수 있습니다.

하나의 파일로 합치기

반드시 이런 방식으로 작성하지 않아도 되지만, reducer와 context를 모두 하나의 파일에 작성하면 컴포넌트들을 조금 더 정리할 수 있습니다. 현재, TasksContext.js는 두 개의 context만을 선언하고 있습니다.

import { createContext } from 'react';

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

이제 이 파일이 좀 더 복잡해질 예정입니다. Reducer를 같은 파일로 옮기고 TasksProvider 컴포넌트를 새로 선언합니다. 이 컴포넌트는 모든 것을 하나로 묶는 역할을 하게 됩니다.

  1. Reducer로 state를 관리합니다.
  2. 두 context를 모두 하위 컴포넌트에 제공합니다.
  3. children을 prop으로 받기 때문에 JSX를 전달할 수 있습니다.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

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

이렇게 하면 TaskApp 컴포넌트의 복잡성과 연결이 모두 제거됩니다.

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

TasksContext.js에서 context를 사용하기 위한 use 함수들도 내보낼 수 있습니다.

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

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

이 함수를 사용하여 컴포넌트에서 context를 읽을 수 있습니다.

const tasks = useTasks();
const dispatch = useTasksDispatch();

이렇게 하면 동작이 바뀌는 건 아니지만, 다음에 context를 더 분리하거나 함수들에 로직을 추가하기 쉬워집니다. 이제 모든 context와 reducer는 TasksContext.js에 있습니다. 이렇게 컴포넌트들이 데이터를 어디서 가져오는지가 아닌 무엇을 보여줄 것인지에 집중할 수 있도록 깨끗하게 정리할 수 있습니다.

import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...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 => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Delete
      </button>
    </label>
  );
}

TasksProvider는 tasks를 화면의 한 부분으로 tasks를 관리합니다. useTasks로 tasks를 읽을 수 있고, useTasksDispatch로 컴포넌트들에서 tasks를 업데이트 할 수 있습니다.

useTasksuseTasksDispatch 같은 함수들을 사용자 정의 Hook이라고 합니다. 이름이 use로 시작되는 함수들은 사용자 정의 Hook입니다. 사용자 정의 Hook 안에서도 useContext 등 다른 Hook을 사용할 수 있습니다.

앱이 커질수록 context-reducer 조합이 더 많아질 겁니다. 앱을 확장하고 큰 노력 없이 트리 아래에서 데이터에 접근할 수 있도록 state를 끌어올리기 위한 강력한 방법이기 때문입니다.

요약

  • Reducer와 context를 결합해서 컴포넌트가 상위 state를 읽고 수정할 수 있도록 할 수 있습니다.
  • State와 dispatch 함수를 하위 컴포넌트들에 제공하는 방법
    1. 두 개의 context를 만듭니다(각각 state와 dispatch 함수를 위한 것).
    2. Reducer를 사용하는 컴포넌트에 두 context를 모두 제공합니다.
    3. 하위 컴포넌트들에서 필요한 context를 사용합니다.
  • 더 나아가 하나의 파일로 합쳐서 컴포넌트들을 정리할 수 있습니다.
    • Context를 제공하는 TasksProvider 같은 컴포넌트를 내보낼 수 있습니다.
    • 바로 사용할 수 있도록 useTasksuseTasksDispatch 같은 사용자 Hook을 내보낼 수 있습니다.
  • context-reducer 조합을 앱에 여러 개 만들 수 있습니다.