React useReducer와 Context 실습 작업 목록 상태 여러 컴포넌트 공유

··설명 약 7분·작성: 해비·블로그 소개

React useReducer와 Context로 작업 목록의 상태 변경을 어떻게 공유하나요?

여러 컴포넌트가 같은 목록을 쓰려면 Provider 한 곳이 state를 소유하고 Context로 state와 dispatch를 전달할 수 있습니다. reducer는 이전 state와 action으로 다음 state를 만들어 변경 규칙을 모읍니다.

본문 근거: Scaling Up with Reducer and Context

독립 실습: reducer 상태를 형제 컴포넌트에서 공유하기

공유 상태를 읽는 컴포넌트와 reducer 액션 결과를 확인하세요.

예제 크게 보기 (새 탭)

JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.

src/App.tsx 전체 코드

import { createContext, useContext, useReducer, type Dispatch } from 'react';
type Action = { type: 'increment' } | { type: 'reset' };
const CounterContext = createContext<{ count: number; dispatch: Dispatch<Action> } | null>(null);
function useCounter() {
  const value = useContext(CounterContext);
  if (!value) throw new Error('CounterContext 공급자가 필요합니다.');
  return value;
}
function Count() {
  const { count } = useCounter();
  return <p>횟수: {count}</p>;
}
function Controls() {
  const { dispatch } = useCounter();
  return (
    <div>
      <button onClick={() => dispatch({ type: 'increment' })}>증가</button>
      <button onClick={() => dispatch({ type: 'reset' })}>초기화</button>
    </div>
  );
}
export default function App() {
  const [count, dispatch] = useReducer((state: number, action: Action) => action.type === 'increment' ? state + 1 : 0, 0);
  return (
    <CounterContext.Provider value={{ count, dispatch }}>
      <Count />
      <Controls />
    </CounterContext.Provider>
  );
}

예상 결과와 직접 확인: 증가 두 번이면 Count에 횟수: 2, 초기화하면 0이 표시되어야 합니다. App이 숫자 state를 소유하고, Controls는 dispatch로 변경을 요청하며, Count는 같은 Provider의 결과를 읽습니다. 이 숫자 예제로 공유 경로를 먼저 확인한 뒤 하단 TaskFlow에서는 state를 작업 배열로 확장합니다.

변형 과제: 새 action을 추가할 때 UI·Action 타입·reducer 중 어디를 수정해야 하는지 정리합니다.

useReducer 기본 구조

const [state, dispatch] = useReducer(
  reducer,
  initialState,
);

state를 직접 변경하는 대신 action을 dispatch합니다.

dispatch({
  type: 'task/toggled',
  id,
});

reducer는 이전 state와 action을 받아 다음 state를 반환합니다.

function reducer(state, action) {
  switch (action.type) {
    case 'task/toggled':
      return state.map((task) =>
        task.id === action.id
          ? { ...task, completed: !task.completed }
          : task,
      );
  }
}

reducer에 변경 규칙을 모읍니다

useState를 사용할 때 App 안에 있던 세 함수가 reducer action으로 바뀝니다.

handleAdd
handleToggle
handleDelete

↓

task/added
task/toggled
task/deleted

이렇게 하면 “어떤 상태 변경이 가능한가?”를 reducer 한 곳에서 확인할 수 있습니다.

Context의 역할

Context는 상태를 전역 변수로 만드는 기능이라기보다, 특정 Provider 아래에서 값을 공유하는 방법입니다.

<TaskProvider>
  <App />
</TaskProvider>

Provider 아래 컴포넌트는 Context를 통해 tasks와 dispatch를 사용할 수 있습니다.

모든 state를 Context에 넣지 않습니다

입력 중인 title처럼 TaskForm 안에서만 필요한 값은 로컬 state가 더 단순합니다. 여러 컴포넌트가 공유해야 하고 변경 규칙이 도메인 수준에서 중요할 때 reducer/context 조합을 검토합니다.

자주 발생하는 오류와 해결

reducer 안에서 state를 직접 수정합니다

잘못된 예:

state.push(newTask);
return state;

새 배열을 반환합니다.

action 문자열 오타

문자열 union 타입으로 action을 정의하면 TypeScript가 잘못된 action을 줄이는 데 도움이 됩니다.

Provider 바깥에서 Context Hook을 호출합니다

Custom Hook에서 undefined를 확인하고 명확한 오류를 발생시키면 찾기 쉽습니다.

학습 완료 체크리스트

  • reducer와 action의 관계를 설명할 수 있습니다.
  • dispatch로 상태 변경을 요청할 수 있습니다.
  • Context Provider를 만들 수 있습니다.
  • 로컬 state와 공유 state를 구분할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.

작업 변경 로직을 taskReducer로 옮기고 TaskProvider에서 tasks와 dispatch를 제공합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 18단계 실행

시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React Cannot update 오류 해결 렌더링 중 setState 원인의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskForm.tsx, src/components/TaskItem.tsx, src/components/TaskList.tsx, src/context/TaskContext.tsx, src/main.tsx, src/reducers/taskReducer.ts, src/types/task.ts. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

변경 순서: src/reducers/taskReducer.ts에 action별 변경을 모으고 src/context/TaskContext.tsx의 TaskProvider를 src/main.tsx에 연결합니다. TaskForm·TaskItem에서 dispatch를 사용합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 추가·완료·삭제 동작을 reducer로 옮긴 뒤 어느 컴포넌트가 상태를 소유하나요?

컴포넌트 실습: step-18-reducer-context에서 작업을 추가·완료·삭제하고 이전 회차와 목록 결과를 비교합니다. App의 작업 변경 함수가 줄고 하위 컴포넌트가 Context의 dispatch를 쓰는 위치를 확인합니다.

예상 화면·상태: 세 동작의 결과는 이전과 같아야 하며 변경 규칙은 reducer의 action 분기에 모입니다. Context 범위 밖에서 훅을 사용하면 오류가 나도록 본문 가드를 확인합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: 동작이 사라지면 action.type과 payload, Provider로 감싼 범위, reducer에서 새 배열을 반환하는지 점검합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 19회에서 반복되는 파생 계산을 custom Hook으로 분리합니다.

완료 코드 ZIP 다운로드 — step-18-reducer-context

이전 체크포인트

step-17-render-state-fix

현재 상태

App이 tasks state와 add·toggle·delete 함수를 모두 가지고 있습니다.

이번 목표

작업 변경 로직을 taskReducer로 옮기고 TaskProvider에서 tasks와 dispatch를 제공합니다.

실행 확인

작업 추가·완료·삭제가 이전과 동일하게 동작하면서 App에서 작업 변경 함수가 사라졌는지 확인합니다.

완료 체크포인트:

step-18-reducer-context

전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.

전체 파일·코드 보기

실습 프로젝트 전체 코드

index.html

<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

package.json

{
  "name": "taskflow-step-18-reducer-context",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "dev": "vite --host 127.0.0.1",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview --host 127.0.0.1"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vite": "8.3.0",
    "@vitejs/plugin-react": "6.1.1",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@types/node": "26.6.2"
  }
}

src/App.tsx

import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import { useEffect, useState } from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import { useTaskContext } from './context/TaskContext';

function App() {
  const { tasks } = useTaskContext();

  const remainingCount = tasks.filter(
    (task) => !task.completed,
  ).length;

  useEffect(() => {
    document.title = `TaskFlow (${remainingCount})`;
  }, [remainingCount]);


  return (
    <main className="app">
      <Header />
      <NetworkStatus />
      <p>남은 작업: {remainingCount}</p>
      <TaskForm />
      <TaskList />
    <StateResetDemo />
    </main>
  );
}

export default App;

src/components/Header.tsx

export default function Header() {
  return (
    <header>
      <p className="eyebrow">React Learning Project</p>
      <h1>TaskFlow</h1>
      <p>오늘 할 일을 하나씩 정리해 보세요.</p>
    </header>
  );
}

src/components/NetworkStatus.tsx

import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
 const [showDebugMessage, setShowDebugMessage] = useState(false);
 const isOnline = useNetworkStatus(showDebugMessage);
 return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
 <label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}

src/components/StateResetDemo.tsx

import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
 { id: 'example-1', title: '첫 번째 작업', completed: false },
 { id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
 const [selectedId, setSelectedId] = useState(examples[0].id);
 const selectedTask = examples.find(task => task.id === selectedId)!;
 return <details><summary>state 초기화 실습</summary>
 <p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
 {examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
 <TaskEditor key={selectedTask.id} task={selectedTask} />
 </details>;
}

src/components/TaskCard.tsx

import type { ReactNode } from 'react';

type TaskCardProps = {
  children: ReactNode;
  actions?: ReactNode;
};

export default function TaskCard({
  children,
  actions,
}: TaskCardProps) {
  return (
    <li className="task-card">
      <div className="task-card__content">
        {children}
      </div>
      {actions ? (
        <div className="task-card__actions">
          {actions}
        </div>
      ) : null}
    </li>
  );
}

src/components/TaskEditor.tsx

import { useState } from 'react';
import type { Task } from '../types/task';

type TaskEditorProps = {
  task: Task;
};

export default function TaskEditor({
  task,
}: TaskEditorProps) {
  const [title, setTitle] = useState(task.title);

  return (
    <section>
      <h2>작업 편집</h2>
      <label htmlFor="editor-title">편집 입력 연습</label>
      <input id="editor-title"
        value={title}
        onChange={(event) => setTitle(event.target.value)}
      />
      <p>선택한 작업 ID: {task.id}</p>
    </section>
  );
}

src/components/TaskForm.tsx

import {
  useRef,
  useState,
  type FormEvent,
} from 'react';
import { useTaskContext } from '../context/TaskContext';

export default function TaskForm() {
  const [title, setTitle] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  const { dispatch } = useTaskContext();

  const handleSubmit = (
    event: FormEvent<HTMLFormElement>,
  ) => {
    event.preventDefault();

    const trimmedTitle = title.trim();

    if (!trimmedTitle) {
      inputRef.current?.focus();
      return;
    }

    dispatch({
      type: 'task/added',
      task: {
        id: crypto.randomUUID(),
        title: trimmedTitle,
        completed: false,
      },
    });

    setTitle('');
    inputRef.current?.focus();
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="task-title">새 작업</label>
      <input
        ref={inputRef}
        id="task-title"
        value={title}
        onChange={(event) =>
          setTitle(event.target.value)
        }
      />
      <button type="submit">추가</button>
    </form>
  );
}

src/components/TaskItem.tsx

import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';

type TaskItemProps = {
  task: Task;
};

export default function TaskItem({
  task,
}: TaskItemProps) {
  const { dispatch } = useTaskContext();

  return (
    <TaskCard>
      <button
        type="button"
        onClick={() =>
          dispatch({
            type: 'task/toggled',
            id: task.id,
          })
        }
      >
        {task.completed ? '완료 취소' : '완료'}
      </button>

      <span>{task.title}</span>

      <button
        type="button"
        onClick={() =>
          dispatch({
            type: 'task/deleted',
            id: task.id,
          })
        }
      >
        삭제
      </button>
    </TaskCard>
  );
}

src/components/TaskList.tsx

import { useTaskContext } from '../context/TaskContext';
import TaskItem from './TaskItem';

export default function TaskList() {
  const { tasks } = useTaskContext();

  if (tasks.length === 0) {
    return <p>등록된 작업이 없습니다.</p>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}

src/context/TaskContext.tsx

import {
  createContext,
  useContext,
  useReducer,
  type Dispatch,
  type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
  Task,
  TaskAction,
} from '../types/task';

type TaskContextValue = {
  tasks: Task[];
  dispatch: Dispatch<TaskAction>;
};

const TaskContext = createContext<
  TaskContextValue | undefined
>(undefined);

const initialTasks: Task[] = [
  {
    id: 'task-1',
    title: 'Reducer와 Context 연결하기',
    completed: false,
  },
];

export function TaskProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [tasks, dispatch] = useReducer(
    taskReducer,
    initialTasks,
  );

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

export function useTaskContext() {
  const value = useContext(TaskContext);

  if (!value) {
    throw new Error(
      'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
    );
  }

  return value;
}

src/hooks/useNetworkStatus.ts

import {
  useEffect,
  useEffectEvent,
  useState,
} from 'react';

export function useNetworkStatus(
  showDebugMessage: boolean,
) {
  const [isOnline, setIsOnline] = useState(
    navigator.onLine,
  );

  const onOnline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 온라인 상태입니다.');
    }

    setIsOnline(true);
  });

  const onOffline = useEffectEvent(() => {
    if (showDebugMessage) {
      console.log('TaskFlow가 오프라인 상태입니다.');
    }

    setIsOnline(false);
  });

  useEffect(() => {
    const handleOnline = () => onOnline();
    const handleOffline = () => onOffline();

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener(
        'online',
        handleOnline,
      );
      window.removeEventListener(
        'offline',
        handleOffline,
      );
    };
  }, []);

  return isOnline;
}

src/index.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: Arial, sans-serif;
  color: #111;
  background: #f5f5f5;
}

.app {
  width: min(720px, calc(100% - 32px));
  margin: 48px auto;
}

.eyebrow {
  color: #666;
  font-size: 14px;
}

ul {
  padding-left: 20px;
}

button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TaskProvider>
      <App />
    </TaskProvider>
  </StrictMode>,
);

src/reducers/taskReducer.ts

import type {
  Task,
  TaskAction,
} from '../types/task';

export function taskReducer(
  tasks: Task[],
  action: TaskAction,
): Task[] {
  switch (action.type) {
    case 'task/added':
      return [...tasks, action.task];

    case 'task/toggled':
      return tasks.map((task) =>
        task.id === action.id
          ? { ...task, completed: !task.completed }
          : task,
      );

    case 'task/deleted':
      return tasks.filter(
        (task) => task.id !== action.id,
      );

    default:
      return tasks;
  }
}

src/types/task.ts

export type Task = {
  id: string;
  title: string;
  completed: boolean;
};

export type TaskAction =
  | {
      type: 'task/added';
      task: Task;
    }
  | {
      type: 'task/toggled';
      id: string;
    }
  | {
      type: 'task/deleted';
      id: string;
    };

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": [
      "ES2022",
      "DOM",
      "DOM.Iterable"
    ],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "types": [
      "vite/client"
    ],
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "src",
    "vite.config.ts"
  ]
}

vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });

글 소개·학습 준비·전체 목차

React · 18/32 · TaskFlow 누적 실습

React useReducer와 Context 실습 작업 목록 상태 여러 컴포넌트 공유

React · 18 / 32 · 필수 학습 · useReducer·Context

여러 컴포넌트에서 하나의 작업 목록을 읽고 바꾸려면 Provider 한 곳이 state를 소유하고, 각 컴포넌트가 Context로 state와 dispatch를 읽도록 구성할 수 있습니다. reducer는 이전 state와 action을 받아 다음 state를 반환해 추가·완료·삭제 규칙을 모읍니다.

useReducer는 상태 변경 규칙을 reducer 함수에 모으고, Context는 필요한 하위 컴포넌트가 상태와 dispatch에 접근할 수 있게 합니다. 두 기능을 함께 사용하면 “상태 변경 규칙”과 “상태를 전달하는 방법”을 분리할 수 있습니다.

이번 글에서 배우는 내용

  • useReducer 기본 구조
  • action과 dispatch
  • reducer가 순수 함수여야 하는 이유
  • Context로 state와 dispatch 공유
  • Provider 범위
  • useState를 무조건 reducer로 바꾸면 안 되는 이유

목차

다음 학습

다음 글에서는 반복되는 파생 계산을 custom Hook으로 분리해 컴포넌트의 렌더링 책임과 로직 책임을 나눕니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 33개 · 필수 27개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기