React useTransition useDeferredValue 검색 UI 최적화

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

React 검색 UI에서 입력 반응과 무거운 목록 갱신을 어떻게 나누나요?

검색창의 입력은 즉시 반응해야 하지만 큰 목록의 재계산은 낮은 우선순위로 처리할 수 있습니다. useTransition과 useDeferredValue로 두 업데이트를 나누고 입력값과 결과 목록의 연결을 확인합니다.

적용 조건·범위: 예상 결과와 직접 확인: 검색 입력은 text를 즉시 갱신하고 목록은 deferredText를 사용합니다. 필터를 켜기 전 1999를 검색하면 항목 1999 한 개, 짝수 필터까지 켜면 0개가 남아야 합니다. 빠른 환경에서는 갱신 중 표시가 보이지 않을 수 있으며, 이 결과 개수는 성능 향상의 근거가 아닙니다.

본문 근거: React useTransition · React useDeferredValue

독립 실습: 입력과 결과 갱신 우선순위 나누기

검색과 선택을 바꾸며 transition·deferred UI를 확인하세요.

예제 크게 보기 (새 탭)

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

src/App.tsx 전체 코드

import { useDeferredValue, useState, useTransition } from 'react';
const words = Array.from({ length: 2000 }, (_, i) => `항목 ${i}`);
export default function App() {
  const [text, setText] = useState('');
  const [evenOnly, setEvenOnly] = useState(false);
  const [pending, startTransition] = useTransition();
  const deferredText = useDeferredValue(text);
  const rows = words.filter((word, index) => word.includes(deferredText) && (!evenOnly || index % 2 === 0));
  return (
    <main>
      <input aria-label="검색" value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => startTransition(() => setEvenOnly(value => !value))}>짝수 필터 전환</button>
      <p>{pending || text !== deferredText ? '갱신 중' : '준비'} · {rows.length}개</p>
      <ul>{rows.map(word => <li key={word}>{word}</li>)}</ul>
    </main>
  );
}

예상 결과와 직접 확인: 검색 입력은 text를 즉시 갱신하고 목록은 deferredText를 사용합니다. 필터를 켜기 전 1999를 검색하면 항목 1999 한 개, 짝수 필터까지 켜면 0개가 남아야 합니다. 빠른 환경에서는 갱신 중 표시가 보이지 않을 수 있으며, 이 결과 개수는 성능 향상의 근거가 아닙니다.

변형 과제: 목록 수를 줄여 비교하되 성능 향상 여부는 같은 데이터와 조작으로 Profiler에서 측정합니다.

useTransition

import { useState, useTransition } from 'react';

function SearchTabs() {
  const [tab, setTab] = useState('all');
  const [isPending, startTransition] = useTransition();

  const handleTabChange = (nextTab: string) => {
    startTransition(() => {
      setTab(nextTab);
    });
  };

  return (
    <>
      <button onClick={() => handleTabChange('all')}>
        전체
      </button>
      {isPending ? <p>화면 갱신 중...</p> : null}
    </>
  );
}

transition 안의 업데이트는 긴급하지 않은 렌더링으로 처리할 수 있습니다.

input value 자체는 transition으로 늦추지 않습니다

텍스트 input은 사용자가 입력하는 즉시 value가 바뀌어야 합니다.

잘못된 예:

startTransition(() => {
  setKeyword(event.target.value);
});

이렇게 input의 실제 value 업데이트 자체를 낮은 우선순위로 만들면 입력 경험이 어색해질 수 있습니다.

useDeferredValue

const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);

const visibleTasks = tasks.filter((task) =>
  task.title
    .toLowerCase()
    .includes(deferredKeyword.toLowerCase()),
);

keyword는 즉시 바뀌고, 무거운 결과 목록은 deferredKeyword를 기준으로 조금 늦게 갱신될 수 있습니다.

useDeferredValue와 debounce 차이

debounce는 일정 시간이 지나야 함수를 실행하는 시간 기반 제어입니다.

useDeferredValue는 React가 렌더링 우선순위를 조절하도록 맡기는 방식입니다.

따라서:
– API 호출 횟수를 줄여야 함 → debounce/throttle을 검토
– 렌더링이 무거워 입력 반응성을 유지하고 싶음 → deferred value를 검토

처럼 목적을 구분합니다.

실제 병목이 없으면 추가하지 않아도 됩니다

항목이 20개뿐인데 useTransition과 useDeferredValue를 모두 넣는다고 자동으로 빨라지는 것은 아닙니다.

복잡성도 비용입니다.

아래 독립 예제는 항목 0부터 항목 1999까지 2,000개를 직접 만듭니다. 입력 상태는 즉시 바꾸고, 검색 목록에는 지연된 값을 쓰며, 짝수 필터 변경은 transition으로 처리해 두 API의 적용 위치를 비교합니다.

자주 발생하는 오류

모든 업데이트를 startTransition으로 감쌉니다

버튼 클릭, input value 등 즉시 보여야 하는 상태까지 낮은 우선순위로 만들 필요는 없습니다.

useDeferredValue를 debounce라고 설명합니다

동작 목적이 다릅니다. 네트워크 요청 빈도 제어와 렌더링 우선순위를 구분합니다.

isPending을 전역 로딩처럼 사용합니다

transition 중이라는 의미이지 서버 요청 전체 상태를 뜻하는 것은 아닙니다.

학습 완료 체크리스트

  • 긴급한 업데이트와 낮은 우선순위 업데이트를 구분할 수 있다
  • input value 자체는 즉시 업데이트해야 하는 이유를 설명할 수 있다
  • useDeferredValue와 debounce 차이를 설명할 수 있다
  • 성능 문제가 없는데 무조건 추가하지 않는다
선택 실습: 할 일 앱에 적용하기

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

이번 단계에서는 검색어 결과 계산에 useDeferredValue를 적용하고, 상태 필터 변경은 transition으로 처리합니다.

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

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

TaskFlow 프로젝트 적용

TaskFlow 25단계 실행

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

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React lazy Suspense 코드 스플리팅 지연 로딩의 완료 상태에서 이어갑니다.

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

이전 완료본과 달라진 파일: package.json, src/App.tsx, src/hooks/useTaskFilter.ts. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

독자 질문과 직접 확인

질문: 검색 결과 계산이 무거워도 input의 글자는 즉시 표시되게 할 수 있나요?

컴포넌트 실습: step-25-transition-deferred에서 검색창에 연속 입력하고 결과 목록, pending 표시, 필터 초기화를 각각 확인합니다. step-23의 useTaskFilter와 step-24의 lazy TaskStats를 유지합니다.

예상 화면·상태: 입력 문자는 즉시 바뀌고 무거운 결과는 뒤따라 갱신될 수 있습니다. 실제 체감 개선은 데이터 양과 측정 결과에 따라 달라지므로 이번 편집에서 성능 향상을 확인했다고 쓰지 않습니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: 입력까지 늦어지면 input value 자체를 transition으로 미뤘는지, 결과가 고착되면 deferred 값과 원본 keyword의 사용 위치를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 26회에서 비동기 작업의 pending·낙관적 화면을 다룹니다.

완료 코드 ZIP 다운로드 — step-25-transition-deferred

이전 체크포인트:

step-24-lazy-suspense

TaskFlow의 검색·상태 필터는 step-23에서 만든 useTaskFilter를 그대로 발전시킵니다. 새로운 검색 상태를 App에 다시 만들지 않습니다. step-24에서 추가한 lazy TaskStats도 그대로 유지합니다.

이번 단계에서는 검색어 결과 계산에 useDeferredValue를 적용하고, 상태 필터 변경은 transition으로 처리합니다.

Before

const normalizedKeyword = keyword
  .trim()
  .toLowerCase();

const visibleTasks = tasks.filter((task) => {
  // keyword와 status를 즉시 반영
});

After

const deferredKeyword = useDeferredValue(keyword);
const [isPending, startTransition] = useTransition();

input의 value는 기존 keyword를 그대로 사용하고, 목록 계산만 deferredKeyword를 사용합니다.

기존 TaskFilter 컴포넌트의 props 구조는 그대로 유지할 수 있습니다. setStatus가 기존과 같은 Dispatch<SetStateAction<TaskStatusFilter>> 형태를 유지하기 때문입니다.

화면 적용

const {
  keyword,
  setKeyword,
  status,
  setStatus,
  visibleTasks,
  resetFilter,
  isStale,
  isPending,
} = useTaskFilter(tasks);

<TaskFilter
  keyword={keyword}
  onKeywordChange={setKeyword}
  status={status}
  onStatusChange={setStatus}
  onReset={resetFilter}
/>

{isStale ? <p>검색 결과 갱신 중...</p> : null}
{isPending ? <p>필터 갱신 중...</p> : null}

<TaskList tasks={visibleTasks} />

step-24에서 추가한 lazy TaskStats와 Suspense는 제거하지 않습니다. 검색·필터 최적화는 기존 화면 구조 위에 추가됩니다.

실행 확인

  • 검색 input은 타이핑 즉시 글자가 표시되는지 확인합니다.
  • 많은 작업이 있을 때 결과 목록이 조금 늦게 따라와도 input이 끊기지 않는지 확인합니다.
  • 상태 필터 변경 중 isPending이 필요한 경우 표시되는지 확인합니다.
  • 필터 초기화 후 keyword와 status가 모두 초기 상태로 돌아오는지 확인합니다.
  • step-24의 지연 로딩 통계가 그대로 동작하는지 확인합니다.
npm run build

완료 체크포인트:

step-25-transition-deferred

전체 코드 뷰어의 모든 파일은 다운로드 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-25-transition-deferred",
  "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'));
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 />
 
 </main>;
}

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/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 useTransition useDeferredValue 검색 UI 최적화 학습 표지

React · 25/32 · TaskFlow 누적 실습

React useTransition useDeferredValue 검색 UI 최적화

React · 25 / 32 · 필수 학습 · useTransition·useDeferredValue

검색 input처럼 즉시 반응해야 하는 UI와, 수천 개 항목을 다시 계산하는 목록처럼 조금 늦게 처리해도 되는 UI는 우선순위가 다릅니다.

이 글에서는 useTransition과 useDeferredValue를 이용해 긴급한 업데이트와 낮은 우선순위 업데이트를 구분하는 방법을 설명합니다. 두 API는 debounce를 대체하는 도구가 아니라 렌더링 우선순위를 조정하는 도구입니다.

이번 글에서 배우는 내용

  • urgent update와 transition update 차이
  • useTransition
  • startTransition
  • isPending
  • useDeferredValue
  • 입력값과 결과 UI를 분리하는 방법
  • debounce와의 차이

목차

다음 학습

다음 글에서는 비동기 작업의 pending/result 상태를 useActionState로 관리하고, 서버 응답 전에 먼저 UI를 보여 주는 useOptimistic 패턴을 다룹니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기