React useActionState와 useOptimistic은 비동기 제출과 실패 복구를 어떻게 나누나요?
useActionState는 비동기 제출의 결과와 대기 상태를 관리하고, useOptimistic은 응답 전에 임시 값을 보여줍니다. 성공하면 확정 state를 갱신하고 실패하면 마지막 확정값으로 돌아와야 합니다.
적용 조건·범위: 저장이 실패하면 사용자에게 오류를 알려 주고 실제 tasks 상태를 기준으로 다시 렌더링해야 합니다.
본문 근거: React useActionState · React useOptimistic
독립 실습: 성공·실패가 있는 낙관적 저장
제목을 저장한 뒤 "실패"도 입력하여 낙관적 표시와 확정값을 비교하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useActionState, useOptimistic, useState } from 'react';
export default function App() {
const [saved, setSaved] = useState('아직 저장 전');
const [optimistic, setOptimistic] = useOptimistic(saved);
const [message, action, pending] = useActionState(async (_previous: string, data: FormData) => {
const title = String(data.get('title') ?? '').trim();
if (!title) return '제목을 입력하세요.';
setOptimistic(title);
await new Promise(resolve => setTimeout(resolve, 600));
if (title === '실패') return '저장 실패: 이전 확정값으로 돌아갑니다.';
setSaved(title);
return '저장 완료';
}, '');
return (
<form action={action}>
<input name="title" aria-label="제목" />
<button disabled={pending}>저장</button>
<p>화면: {optimistic}</p>
<p>확정값: {saved}</p>
<p role="status">{pending ? '저장 중' : message}</p>
</form>
);
}
예상 결과와 직접 확인: 독서를 제출하면 먼저 화면 값에 독서가 표시되고, 설정한 600ms 대기를 마친 뒤 확정값도 독서가 됩니다. 이어서 실패를 제출하면 화면 값만 잠시 실패가 되었다가 확정값 독서로 복구해야 합니다. 실제 서버나 영구 저장은 사용하지 않으며 화면 반영 시간은 실측하지 않았습니다.
변형 과제: 지연 시간을 1500ms로 바꾸어 pending 중 버튼이 비활성화되는 이유와, 실패 분기에서 확정값을 갱신하지 않아도 화면이 되돌아오는 이유를 설명합니다.
useActionState
form action과 비동기 함수를 연결하면 제출 상태를 한 흐름으로 관리할 수 있습니다.
import { useActionState } from 'react';
type SaveState = {
message: string;
};
async function saveSettings(
previousState: SaveState,
formData: FormData,
): Promise<SaveState> {
const name = String(formData.get('name') ?? '');
await saveName(name);
return {
message: '저장했습니다.',
};
}
const [state, formAction, isPending] = useActionState(
saveSettings,
{ message: '' },
);
폼에서는 action에 연결합니다.
<form action={formAction}>
<input name="name" />
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
<p>{state.message}</p>
</form>
useOptimistic
useOptimistic은 실제 데이터가 확정되기 전에 임시 UI 상태를 보여 줄 수 있게 합니다.
const [optimisticTasks, setOptimisticTask] =
useOptimistic(
tasks,
(currentTasks, taskId: string) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
completed: !task.completed,
}
: task,
),
);
사용자가 완료 버튼을 누르면 먼저 optimistic state를 갱신하고 실제 저장을 시작할 수 있습니다.
낙관적 UI가 적합한 작업
- 좋아요
- 완료/미완료 토글
- 간단한 즐겨찾기
- 실패 가능성이 낮고 복구가 쉬운 변경
낙관적 UI가 위험한 작업
- 결제 완료
- 권한 부여
- 재고 확정
- 삭제 복구가 어려운 작업
- 서버 검증 결과가 UI 의미를 크게 바꾸는 작업
낙관적 UI는 “빠르게 보이게 하는 기술”이 아니라 실패해도 원래 상태로 돌아갈 수 있는 작업에 적용하는 UX 전략입니다.
자주 발생하는 오류
optimistic state를 진짜 원본 state처럼 사용합니다
optimistic state는 임시 UI입니다. 실제 서버 결과가 원본과 맞아야 합니다.
실패 처리를 하지 않습니다
저장이 실패하면 사용자에게 오류를 알려 주고 실제 tasks 상태를 기준으로 다시 렌더링해야 합니다.
모든 비동기 state를 useActionState 하나로 해결하려 합니다
일반 이벤트, 데이터 패칭 라이브러리, 프레임워크 Server Action 등 상황에 따라 적절한 도구가 다릅니다.
학습 완료 체크리스트
- useActionState가 관리하는 값을 설명할 수 있다
- useOptimistic의 임시 상태 의미를 설명할 수 있다
- 낙관적 UI에 적합하지 않은 작업을 구분할 수 있다
- 실패 후 실제 데이터로 되돌리는 기준을 설명할 수 있다
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
저장 중 pending과 낙관적 완료 표시를 붙이고 성공·실패 뒤 실제 작업 상태를 비교합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 26단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React useTransition useDeferredValue 검색 UI 최적화의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/actions/saveProfile.ts, src/actions/updateTask.ts, src/components/AsyncDemo.tsx, src/components/OptimisticTaskList.tsx, src/components/ProfileSaveForm.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자 질문과 직접 확인
질문: 저장 응답을 기다리는 동안 화면에는 무엇을 보여주고 실패하면 어떻게 되돌리나요?
컴포넌트 실습: step-26-actions-optimistic에서 본문이 제공하는 프로필 저장과 작업 완료 지연 예제를 각각 실행합니다. pending 표시, 즉시 바뀐 완료 표시, 성공·실패 후 실제 tasks 상태를 순서대로 기록합니다.
예상 화면·상태: 저장 중 pending이 보이고 낙관적 완료 표시는 응답 전에 나타날 수 있습니다. 실패 뒤에는 실제 상태와 화면이 다시 일치해야 합니다. 예제 동작과 실제 서버 저장은 구분합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 실패 뒤 표시가 남으면 optimistic 값과 확정 state의 소유·복구 경로를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 27회에서 목록·상세·설정 화면을 URL로 연결합니다.
완료 코드 ZIP 다운로드 — step-26-actions-optimistic
이전 체크포인트:
step-25-transition-deferred
기존 TaskFlow의 작업 완료 버튼은 즉시 로컬 state를 바꿉니다.
이번 단계에서는 별도의 실습 목록에서 서버 저장을 흉내 내고:
1. UI는 먼저 완료 상태로 표시
2. 비동기 저장
3. 성공 시 실제 tasks 반영
4. 실패 시 실제 tasks 기준으로 원래 화면 복구
흐름을 연습합니다.
실제 서버나 영구 저장소는 연결하지 않습니다. 기존 작업 목록은 로컬 state를 유지하고, 낙관적 UI 실습 목록에서 성공과 실패를 비교합니다. 실패용 작업은 의도적으로 오류를 발생시키며 대기 중에는 중복 요청을 막습니다.
변경 파일
src/
├─ actions/
│ ├─ saveProfile.ts
│ └─ updateTask.ts
├─ components/
│ ├─ ProfileSaveForm.tsx
│ └─ OptimisticTaskList.tsx
└─ App.tsx
useActionState 예제
useOptimistic 예제
이 예제에서 실제 tasks state는 부모가 계속 소유합니다. optimisticTasks는 서버 결과가 확정되기 전 UI에만 사용합니다.
실행 확인
- 프로필 저장 버튼을 누르면 pending 문구가 표시되는지 확인합니다.
- 작업 완료 버튼은 서버 지연 시간보다 먼저 UI가 바뀌는지 확인합니다.
- 성공 후 실제 tasks state와 화면이 일치하는지 확인합니다.
- 실패 시 실제 tasks 상태로 자연스럽게 돌아가는지 확인합니다.
npm run build
완료 체크포인트:
step-26-actions-optimistic
누적 프로젝트 연결 기준
이번 회차의 ProfileSaveForm과 OptimisticTaskList는 비동기 Action과 낙관적 UI를 안전하게 분리해 확인하기 위한 학습용 컴포넌트입니다.
- ProfileSaveForm은 다음 React Router 회차에서 /settings 화면으로 이동해 계속 사용합니다.
- OptimisticTaskList는 기존 TaskList를 영구 교체하지 않고 비동기 토글 패턴을 확인하는 실습 컴포넌트로 남깁니다.
- 기존 TaskFlow의 검색·필터·lazy TaskStats 구조는 제거하지 않습니다.
따라서 다음 회차에서는 step-25까지 누적된 기본 화면 구조를 유지하면서 Router를 추가하고, 이번 회차의 설정 폼만 SettingsPage에 연결합니다.
전체 코드 뷰어의 모든 파일은 다운로드 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-26-actions-optimistic",
"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",
"react-hook-form": "7.88.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 { useEffect, lazy, Suspense } from 'react';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import StateResetDemo from './components/StateResetDemo';
import NetworkStatus from './components/NetworkStatus';
import TaskSummary from './components/TaskSummary';
import { useTaskContext } from './context/TaskContext';
import { useTaskStats } from './hooks/useTaskStats';
import { useTaskFilter } from './hooks/useTaskFilter';
import Header from './components/Header';
import StatsSkeleton from './components/StatsSkeleton';
const TaskStats = lazy(() => import('./components/TaskStats'));
import ProfileSaveForm from './components/ProfileSaveForm';
import AsyncDemo from './components/AsyncDemo';
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <main className="app">
<Header />
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
<p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
<TaskList tasks={visibleTasks} />
<Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
<StateResetDemo />
<ProfileSaveForm /><AsyncDemo />
</main>;
}
src/actions/saveProfile.ts
export type SaveProfileState = {
message: string;
};
export async function saveProfile(
_previousState: SaveProfileState,
formData: FormData,
): Promise<SaveProfileState> {
const displayName = String(
formData.get('displayName') ?? '',
).trim();
await new Promise((resolve) =>
setTimeout(resolve, 500),
);
if (!displayName) {
return {
message: '이름을 입력해 주세요.',
};
}
return {
message: '프로필을 저장했습니다.',
};
}
src/actions/updateTask.ts
export async function updateTaskOnServer(
taskId: string,
): Promise<void> {
await new Promise((resolve) =>
setTimeout(resolve, 600),
);
if (!taskId || taskId === 'fail') {
throw new Error('작업 ID가 없습니다.');
}
}
src/components/AsyncDemo.tsx
import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
<OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}
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/OptimisticTaskList.tsx
import {
useTransition,
useOptimistic,
useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';
type OptimisticTaskListProps = {
tasks: Task[];
onCommitToggle: (taskId: string) => void;
};
export default function OptimisticTaskList({
tasks,
onCommitToggle,
}: OptimisticTaskListProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] =
useState('');
const [optimisticTasks, toggleOptimistic] =
useOptimistic(
tasks,
(currentTasks, taskId: string) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
completed: !task.completed,
}
: task,
),
);
const handleToggle = (taskId: string) => {
setErrorMessage('');
startTransition(async () => {
toggleOptimistic(taskId);
try {
await updateTaskOnServer(taskId);
startTransition(() => onCommitToggle(taskId));
} catch {
setErrorMessage(
'저장에 실패했습니다. 기존 상태로 돌아갑니다.',
);
}
});
};
return (
<section>
{errorMessage ? (
<p role="alert">{errorMessage}</p>
) : null}
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
<button
type="button"
disabled={isPending}
onClick={() => handleToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
</li>
))}
</ul>
</section>
);
}
src/components/ProfileSaveForm.tsx
import { useActionState } from 'react';
import {
saveProfile,
type SaveProfileState,
} from '../actions/saveProfile';
const initialState: SaveProfileState = {
message: '',
};
export default function ProfileSaveForm() {
const [state, formAction, isPending] =
useActionState(saveProfile, initialState);
return (
<form action={formAction}>
<label>
표시 이름
<input name="displayName" />
</label>
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
<p>{state.message}</p>
</form>
);
}
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/StatsSkeleton.tsx
export default function StatsSkeleton() {
return (
<section aria-busy="true">
<h2>작업 통계</h2>
<p>통계를 불러오는 중...</p>
</section>
);
}
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/TaskFilter.tsx
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { TaskStatusFilter } from '../types/task';
type TaskFilterProps = {
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
status: TaskStatusFilter;
onStatusChange: Dispatch<
SetStateAction<TaskStatusFilter>
>;
onReset: () => void;
};
export default function TaskFilter({
keyword,
onKeywordChange,
status,
onStatusChange,
onReset,
}: TaskFilterProps) {
return (
<section>
<label htmlFor="task-keyword">
작업 검색
</label>
<input
id="task-keyword"
value={keyword}
onChange={(event) =>
onKeywordChange(event.target.value)
}
/>
<label htmlFor="task-status">
상태
</label>
<select
id="task-status"
value={status}
onChange={(event) =>
onStatusChange(
event.target.value as TaskStatusFilter,
)
}
>
<option value="all">전체</option>
<option value="active">진행 중</option>
<option value="completed">완료</option>
</select>
<button type="button" onClick={onReset}>
필터 초기화
</button>
</section>
);
}
src/components/TaskForm.tsx
import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';
type TaskFormValues = {
title: string;
};
export default function TaskForm() {
const { dispatch } = useTaskContext();
const {
register,
handleSubmit,
setValue,
setFocus,
formState: { errors },
} = useForm<TaskFormValues>({
defaultValues: {
title: '',
},
});
const onSubmit = (values: TaskFormValues) => {
const title = values.title.trim();
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title,
completed: false,
},
});
setValue('title', '');
setFocus('title');
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="task-title">
새 작업
</label>
<input
id="task-title"
aria-invalid={Boolean(errors.title)}
aria-describedby={
errors.title ? 'task-title-error' : undefined
}
{...register('title', {
setValueAs: (value: string) => value.trim(),
required: '작업 제목을 입력해 주세요.',
minLength: {
value: 2,
message: '작업 제목은 2자 이상 입력해 주세요.',
},
validate: (value) =>
value.trim().length > 0 ||
'공백만 입력할 수 없습니다.',
})}
/>
{errors.title && (
<p id="task-title-error" role="alert">
{errors.title.message}
</p>
)}
<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 type { Task } from '../types/task';
import TaskItem from './TaskItem';
type TaskListProps = {
tasks: Task[];
};
export default function TaskList({
tasks,
}: TaskListProps) {
const { tasks: allTasks } = useTaskContext();
if (allTasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
if (tasks.length === 0) {
return <p>조건에 맞는 작업이 없습니다.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
src/components/TaskStats.tsx
import type { Task } from '../types/task';
type TaskStatsProps = {
tasks: Task[];
};
export default function TaskStats({
tasks,
}: TaskStatsProps) {
const completedCount = tasks.filter(
(task) => task.completed,
).length;
return (
<section>
<h2>작업 통계</h2>
<p>전체: {tasks.length}</p>
<p>완료: {completedCount}</p>
<p>진행 중: {tasks.length - completedCount}</p>
</section>
);
}
src/components/TaskSummary.tsx
type TaskSummaryProps = {
total: number;
completed: number;
remaining: number;
label?: string;
};
export default function TaskSummary({
total,
completed,
remaining,
label = '작업 현황',
}: TaskSummaryProps) {
return (
<section aria-label={label}>
<p>
전체 {total} · 완료 {completed} · 남음 {remaining}
</p>
</section>
);
}
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/hooks/useTaskFilter.ts
import {
useDeferredValue,
useState,
useTransition,
type Dispatch,
type SetStateAction,
} from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatusState] =
useState<TaskStatusFilter>('all');
const [isPending, startTransition] =
useTransition();
const deferredKeyword =
useDeferredValue(keyword);
const normalizedKeyword = deferredKeyword
.trim()
.toLowerCase();
const visibleTasks = tasks.filter((task) => {
const matchesKeyword = task.title
.toLowerCase()
.includes(normalizedKeyword);
const matchesStatus =
status === 'all' ||
(status === 'active' && !task.completed) ||
(status === 'completed' && task.completed);
return matchesKeyword && matchesStatus;
});
const setStatus: Dispatch<
SetStateAction<TaskStatusFilter>
> = (nextStatus) => {
startTransition(() => {
setStatusState(nextStatus);
});
};
const resetFilter = () => {
setKeyword('');
startTransition(() => {
setStatusState('all');
});
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
isStale: keyword !== deferredKeyword,
isPending,
};
}
src/hooks/useTaskStats.ts
import { useTaskContext } from '../context/TaskContext';
export function useTaskStats() {
const { tasks } = useTaskContext();
const totalCount = tasks.length;
const completedCount = tasks.filter(
(task) => task.completed,
).length;
const remainingCount =
totalCount - completedCount;
return {
totalCount,
completedCount,
remainingCount,
};
}
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 TaskId = string;
export type Task = {
id: TaskId;
title: string;
completed: boolean;
};
export type TaskAction =
| {
type: 'task/added';
task: Task;
}
| {
type: 'task/toggled';
id: TaskId;
}
| {
type: 'task/deleted';
id: TaskId;
};
export type TaskStatusFilter = 'all' | 'active' | 'completed';
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 · 26/32 · TaskFlow 누적 실습
React useActionState useOptimistic 비동기 액션 낙관적 UI
React · 26 / 32 · 필수 학습 · useActionState·useOptimistic
useActionState는 비동기 제출의 결과와 대기 상태를 관리하고, useOptimistic은 처리가 끝나기 전 임시 값을 화면에 보여 줍니다. 성공하면 확정 state를 갱신하고 실패하면 마지막 확정값으로 화면을 맞추는 흐름을 독립 폼에서 확인합니다.
이 글에서는 비동기 Action의 상태를 useActionState로 관리하고, 안전한 작업에 한해 useOptimistic으로 먼저 화면을 바꾸는 방법을 설명합니다.
이번 글에서 배우는 내용
- React Action 개념
- useActionState
- pending·result 상태
- useOptimistic
- 낙관적 UI
- 실패 시 실제 데이터와 다시 맞추는 방법
- optimistic update를 피해야 하는 경우
목차
다음 학습
다음 글에서는 TaskFlow를 목록·상세·설정 URL로 나누고 React Router를 처음 설치해 화면과 주소를 연결합니다.
React의 Action과 optimistic UI 개념을 이해해 두면 이후 Next.js의 Server Actions나 TanStack Query mutation을 배울 때 역할 차이를 비교하기 쉬워집니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.