Scaling Up with Reducer and Context

김동현·2026년 3월 15일

안녕하세요! 프론트엔드 개발이라는 멋진 길을 걷고 계신 것을 환영합니다. 1년 넘게 꾸준히 준비하시면서 이렇게 공식 문서를 직접 번역하며 깊이 있게 공부하시는 모습이 정말 멋지네요. 영어로 된 기술 문서를 읽는 게 처음엔 시간이 꽤 걸리고 지치기도 하지만, 이 과정이 나중에는 어떤 새로운 기술이 나와도 두려움 없이 파고들 수 있는 강력한 무기가 될 거예요.

오늘 배울 'Reducer와 Context를 함께 사용하기'는 실무에서도 정말 많이 쓰이는 패턴이에요. 예를 들어, 포트폴리오로 만들고 계신 웹 프로필이나 독후감 사이트처럼 여러 컴포넌트에서 하나의 상태를 공유하고 업데이트해야 할 때 아주 유용하답니다.

그럼 딱딱한 번역투 대신, 제가 옆에서 설명해 드리는 것처럼 편안한 구어체로 원문 내용을 하나도 빠짐없이, 그리고 이해하기 쉽도록 살을 붙여서 설명해 드릴게요. 시작해 볼까요?


title: Reducer와 Context로 확장하기 (Scaling Up with Reducer and Context)

Reducer를 사용하면 컴포넌트의 상태 업데이트 로직을 한 곳으로 모아서 깔끔하게 정리할 수 있어요. 그리고 Context를 사용하면 트리 깊숙한 곳에 있는 다른 컴포넌트들에게 정보를 쉽게 전달할 수 있죠. 이 두 가지(Reducer와 Context)를 함께 결합하면 복잡한 화면의 상태를 아주 효율적으로 관리할 수 있답니다.

이 문서를 읽고 나면 이런 것들을 배우게 될 거예요:

  • Reducer와 Context를 어떻게 결합하는지
  • Props를 통해 상태(state)와 디스패치(dispatch) 함수를 일일이 전달하는 것을 어떻게 피할 수 있는지
  • Context와 상태 관리 로직을 별도의 파일로 분리해서 코드를 깔끔하게 유지하는 방법

Reducer와 Context 결합하기 {/combining-a-reducer-with-context/}

Reducer로 상태 로직 추출하기(the introduction to reducers) 파트에서 봤던 예제를 다시 살펴볼게요. 이 예제에서 상태는 Reducer에 의해 관리되고 있어요. 상태 업데이트 로직이 전부 들어있는 Reducer 함수는 이 파일의 가장 아래쪽에 선언되어 있습니다.

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

(강사의 보충 설명: Reducer를 쓰면 상태 변경 로직이 컴포넌트 밖으로 빠지기 때문에 컴포넌트 자체는 훨씬 깔끔해져요. 그런데 컴포넌트 트리가 깊어지면 여전히 귀찮은 문제가 하나 생기게 됩니다.)

Reducer는 이벤트 핸들러를 짧고 간결하게 유지하는 데 큰 도움을 줍니다. 하지만 앱이 점점 커지다 보면 또 다른 어려움에 부딪힐 수 있어요. 현재 상황을 보면, tasks 상태 데이터와 이를 업데이트하는 dispatch 함수는 최상위 컴포넌트인 TaskApp에서만 사용할 수 있습니다. 만약 다른 하위 컴포넌트들에서 할 일 목록을 읽어오거나 내용을 수정하고 싶다면, 현재 상태와 상태를 변경하는 이벤트 핸들러들을 일일이 명시적으로 Props를 통해 아래로 전달(pass down)해 주어야 해요.

예를 들어 볼까요? TaskApp은 할 일 목록과 이벤트 핸들러들을 TaskList라는 자식 컴포넌트에게 Props로 넘겨주고 있죠.

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

그리고 TaskList는 전달받은 이벤트 핸들러들을 또다시 그 하위 컴포넌트인 Task에게 넘겨줍니다.

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

이렇게 규모가 작은 예제에서는 Props로 전달하는 게 전혀 문제 될 게 없고 아주 잘 동작해요. 하지만 중간에 수십, 수백 개의 컴포넌트가 껴있다고 상상해 보세요. 모든 상태와 함수들을 거쳐가는 모든 컴포넌트마다 Props로 계속 넘겨줘야 한다면, 정말 골치 아프고 답답한 작업이 될 거예요! (이를 프론트엔드 용어로는 'Prop Drilling'이라고 부른답니다.)

바로 이런 이유 때문에, 상태와 함수를 Props로 일일이 넘기는 대신, tasks 상태와 dispatch 함수 이 두 가지를 모두 Context에 집어넣는 것을 대안으로 고려해 볼 수 있어요. 이렇게 Context를 활용하면, 트리의 TaskApp 아래에 있는 어떤 컴포넌트라도 반복적이고 지루한 "Prop Drilling" 없이 직접 할 일 목록을 읽어오고 액션을 디스패치(dispatch)할 수 있게 됩니다.

Reducer와 Context를 어떻게 결합하는지 단계별로 알아볼게요:

  1. Context를 생성(Create) 합니다.
  2. 상태(state)와 디스패치(dispatch)를 Context에 넣습니다(Put).
  3. 트리 내의 어디서든 Context를 사용(Use) 합니다.

1단계: Context 생성하기 {/step-1-create-the-context/}

useReducer Hook은 현재 상태인 tasks와 이를 업데이트할 수 있게 해주는 dispatch 함수를 배열 형태로 반환합니다:

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

이 값들을 컴포넌트 트리 아래로 내려보내기 위해, 우리는 두 개의 분리된 Context를 생성할 거예요:

  • TasksContext: 현재 할 일 목록(데이터)을 제공하는 역할을 합니다.
  • TasksDispatchContext: 컴포넌트들이 액션을 보낼 수 있도록 해주는 디스패치 함수를 제공하는 역할을 합니다.

나중에 다른 파일들에서 쉽게 가져다 쓸 수 있도록, 이 Context들을 별도의 파일로 만들어서 export 해볼게요:

// 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>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 }
];
// src/TasksContext.js active
import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
// 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; }

여기 코드(TasksContext.js)를 보시면 두 Context의 기본값으로 null을 넘겨주고 있어요. 실제로 의미 있는 데이터(값)는 나중에 최상위 컴포넌트인 TaskApp에서 제공해 줄 거랍니다.

2단계: 상태와 디스패치를 Context에 넣기 {/step-2-put-state-and-dispatch-into-context/}

이제 방금 만든 두 개의 Context를 TaskApp 컴포넌트로 불러올(import) 수 있습니다. useReducer()가 반환한 tasks 배열과 dispatch 함수를 가져와서, 그 아래에 있는 전체 컴포넌트 트리에게 제공(provide)해 보겠습니다:

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

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

(강사의 보충 설명: Context 컴포넌트는 반드시 value라는 속성(prop)을 통해 데이터를 전달해야 해요. 이렇게 감싸주면 저 점 3개(...) 안에 들어갈 어떤 자식 컴포넌트라도 저 value에 접근할 수 있게 되는 원리랍니다.)

일단 지금은 정보 전달을 Props로도 하고, Context로도 하는 과도기적인 코드를 살펴볼게요:

// src/App.js
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 value={tasks}>
      <TasksDispatchContext value={dispatch}>
        <h1>Day off in Kyoto</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </TasksDispatchContext>
    </TasksContext>
  );
}

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

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
// 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; }

다음 단계에서는 이렇게 지저분하게 넘겨주던 Props 전달을 완전히 제거해 버릴 거예요!

3단계: 트리 어디서나 Context 사용하기 {/step-3-use-context-anywhere-in-the-tree/}

이제 컴포넌트 트리 아래로 할 일 목록이나 이벤트 핸들러를 번거롭게 넘겨줄 필요가 없어졌어요:

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

대신, 할 일 목록 데이터가 필요한 컴포넌트는 언제든지 TasksContext에서 직접 값을 읽어오면 됩니다:

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

할 일 목록을 업데이트하고 싶을 때도 마찬가지예요. 어떤 컴포넌트든 Context에서 dispatch 함수를 읽어와서 호출하기만 하면 됩니다:

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

결과적으로 TaskApp 컴포넌트는 하위로 그 어떤 이벤트 핸들러도 전달하지 않게 되었습니다. TaskList 역시 Task 컴포넌트에게 어떠한 이벤트 핸들러도 전달하지 않죠. 이제 각 컴포넌트는 자신이 필요한 Context를 알아서 직접 읽어오기만 하면 됩니다:

// src/App.js
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
  );

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        <h1>Day off in Kyoto</h1>
        <AddTask />
        <TaskList />
      </TasksDispatchContext>
    </TasksContext>
  );
}

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

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
// src/AddTask.js
import { useState, useContext } from 'react';
import { TasksDispatchContext } from './TasksContext.js';

export default function AddTask() {
  const [text, setText] = useState('');
  const dispatch = useContext(TasksDispatchContext);
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        dispatch({
          type: 'added',
          id: nextId++,
          text: text,
        }); 
      }}>Add</button>
    </>
  );
}

let nextId = 3;
// src/TaskList.js active
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>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }

상태는 여전히 useReducer를 통해 관리되며 최상위 컴포넌트인 TaskApp에 "살아(lives)" 있습니다. 하지만 이제 그 안의 tasks 데이터와 dispatch 함수는 이 Context들을 가져와서 사용하는 것만으로도 하위 컴포넌트 어디서든 접근할 수 있게 된 거죠.

모든 연결 구조를 하나의 파일로 옮기기 {/moving-all-wiring-into-a-single-file/}

반드시 이렇게 해야만 하는 건 아니지만, 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를 모두 제공(provide)할 겁니다.
  3. JSX를 자식으로 전달받을 수 있도록 children을 prop으로 받을 겁니다.

(강사의 보충 설명: 이렇게 로직을 전담하는 파일을 분리해두면, UI를 그리는 컴포넌트와 데이터의 흐름을 관리하는 컴포넌트가 명확히 분리되어서 유지보수가 엄청나게 쉬워집니다. 나중에 면접관들이 코드를 볼 때 "아, 이 개발자는 관심사 분리(Separation of Concerns)를 잘 이해하고 있구나" 하고 긍정적으로 평가하는 포인트가 되기도 하죠!)

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

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

이렇게 하면 TaskApp 컴포넌트 안에 있던 복잡한 로직과 연결(wiring) 코드들을 전부 제거할 수 있어요:

// src/App.js
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>
  );
}
// src/TasksContext.js
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 value={tasks}>
      <TasksDispatchContext value={dispatch}>
        {children}
      </TasksDispatchContext>
    </TasksContext>
  );
}

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

export default function AddTask() {
  const [text, setText] = useState('');
  const dispatch = useContext(TasksDispatchContext);
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        dispatch({
          type: 'added',
          id: nextId++,
          text: text,
        }); 
      }}>Add</button>
    </>
  );
}

let nextId = 3;
// src/TaskList.js
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>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }

뿐만 아니라 TasksContext.js 파일에서 Context를 사용(use) 하는 함수 자체를 만들어서 내보낼(export) 수도 있어요:

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

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

이제 어떤 컴포넌트든 Context를 읽어와야 할 때, 복잡하게 useContext를 쓰지 않고 이 함수들을 통해 바로 접근할 수 있습니다:

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

동작 자체는 이전과 완전히 동일하지만, 이렇게 해두면 나중에 이 Context들을 더 잘게 쪼개거나 이 함수들 안에 추가적인 로직을 넣을 때 아주 유용해요. 이제 모든 Context와 Reducer의 연결 고리가 TasksContext.js 파일 하나에 모이게 되었습니다. 컴포넌트들은 '데이터를 어디서 가져올까' 고민할 필요 없이 오직 '무엇을 화면에 보여줄까'에만 집중할 수 있게 되어 코드가 아주 깨끗해졌죠.

// src/App.js
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>
  );
}
// src/TasksContext.js
import { createContext, useContext, useReducer } from 'react';

const TasksContext = createContext(null);

const TasksDispatchContext = createContext(null);

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

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

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

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

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

export default function AddTask() {
  const [text, setText] = useState('');
  const dispatch = useTasksDispatch();
  return (
    <>
      <input
        placeholder="Add task"
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <button onClick={() => {
        setText('');
        dispatch({
          type: 'added',
          id: nextId++,
          text: text,
        }); 
      }}>Add</button>
    </>
  );
}

let nextId = 3;
// src/TaskList.js active
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>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }

정리하자면 이렇게 이해하시면 됩니다: TasksProvider는 할 일 데이터를 다룰 줄 아는 화면의 일부이고, useTasks는 그 데이터를 읽는 도구이며, useTasksDispatch는 하위 컴포넌트 어디서든 그 데이터를 업데이트할 수 있게 해주는 도구입니다.

(강사의 보충 설명: 이렇게 use로 시작하는 나만의 함수를 만드는 것을 Custom Hook이라고 해요!)
useTasksuseTasksDispatch 같은 함수들을 우리는 커스텀 훅(Custom Hooks) 이라고 부릅니다. 여러분이 만든 함수 이름이 use로 시작한다면, 그것은 커스텀 훅으로 간주됩니다. 커스텀 훅을 만들면 그 안에서 useContext처럼 다른 내장 Hook들을 자유롭게 사용할 수 있다는 장점이 있습니다.

여러분의 앱이 점점 커지다 보면, 이처럼 Context와 Reducer가 짝을 이루는 경우를 많이 만들게 될 거예요. 이것은 상태를 위로 끌어올리면서도(lift state up) 트리의 깊은 곳에서 데이터에 접근할 때 불필요한 작업(Prop drilling)을 피할 수 있게 해주는, 앱 확장에 있어 아주 강력하고 훌륭한 패턴입니다.

오늘 배운 핵심 내용 요약 (Recap)

  • Reducer와 Context를 결합하면, 어떤 컴포넌트든 자신의 상위에 있는 상태를 읽고 업데이트할 수 있게 만들 수 있습니다.
  • 하위 컴포넌트들에게 상태와 디스패치 함수를 제공하려면 다음 과정을 따르세요:
    1. 두 개의 Context를 만듭니다 (하나는 상태용, 하나는 디스패치 함수용).
    2. Reducer를 사용하는 컴포넌트에서 이 두 Context를 모두 제공(Provide)합니다.
    3. 값이 필요한 하위 컴포넌트에서 필요한 Context를 골라 사용합니다.
  • 복잡한 연결 고리(wiring)를 하나의 파일로 모두 옮기면 컴포넌트들을 훨씬 깔끔하게 정리할 수 있습니다.
    - Context를 제공하는 TasksProvider 같은 래퍼 컴포넌트를 만들어 export 할 수 있습니다.
    - 그 Context의 값을 쉽게 읽어올 수 있도록 useTasksuseTasksDispatch 같은 커스텀 Hook을 만들어 export 할 수도 있습니다.
  • 여러분의 앱 내에는 이렇게 Context와 Reducer가 짝을 이룬 구조를 여러 개 만들어 사용할 수 있습니다.

사이트맵 (Sitemap)

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


어떠셨나요? 생각보다 논리적인 흐름으로 잘 짜여 있죠? 이 패턴을 잘 익혀두시면 앞으로 진행하실 프론트엔드 프로젝트나 면접에서도 큰 자신감을 얻으실 수 있을 거예요. 혹시 공부하시다가 헷갈리거나 더 깊게 파고들고 싶은 부분이 생기면 언제든 편하게 물어보세요! 다음 문서 번역도 도와드릴까요?

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

0개의 댓글