React에서 검색어와 완료 상태를 함께 적용한 결과 목록을 어떻게 만드나요?
검색 결과는 원본 목록·검색어·완료 조건으로 계산할 수 있으므로 별도 state로 복제하지 않아도 됩니다. 검색어와 필터를 함께 적용한 교집합을 렌더링하고, 결과가 없을 때와 조건을 초기화했을 때를 나누어 표시합니다.
독립 실습: 검색어와 완료 조건의 교집합 구하기
검색어와 상태 필터를 바꿔 표시 목록을 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState } from 'react';
type Status = 'all' | 'active' | 'completed';
const tasks = [
{ id: 'a', title: 'React 읽기', completed: false },
{ id: 'b', title: 'React 실습', completed: true },
{ id: 'c', title: '산책', completed: false },
];
export default function App() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState<Status>('all');
const visible = tasks.filter(task =>
task.title.toLowerCase().includes(keyword.trim().toLowerCase()) &&
(status === 'all' || (status === 'completed' ? task.completed : !task.completed))
);
return (
<main>
<input aria-label="검색" value={keyword} onChange={e => setKeyword(e.target.value)} />
<select aria-label="상태" value={status} onChange={e => setStatus(e.target.value as Status)}>
<option value="all">전체</option><option value="active">진행 중</option><option value="completed">완료</option>
</select>
<button onClick={() => { setKeyword(''); setStatus('all'); }}>초기화</button>
{visible.length ? <ul>{visible.map(task => <li key={task.id}>{task.title}</li>)}</ul> : <p>결과 없음</p>}
</main>
);
}
직접 확인: 검색어 REACT와 완료 조건에는 React 실습만 보입니다. 없는 단어에는 결과 없음, 초기화에는 3개가 표시됩니다.
변형 과제: 산책+완료 조합이 빈 결과인 이유를 두 조건 각각으로 설명합니다.
검색 결과는 파생 데이터입니다
const visibleTasks = tasks.filter((task) =>
task.title
.toLowerCase()
.includes(keyword.toLowerCase()),
);
tasks와 keyword가 이미 state이므로 visibleTasks까지 state로 만들 필요가 없습니다.
잘못된 패턴:
const [visibleTasks, setVisibleTasks] = useState<Task[]>([]);
useEffect(() => {
setVisibleTasks(
tasks.filter(/* ... */),
);
}, [tasks, keyword]);
불필요한 state와 Effect가 추가됩니다.
여러 조건 조합
const normalizedKeyword = keyword
.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;
});
상태 타입
문자열 전체를 허용하기보다 가능한 값을 union으로 제한합니다.
export type TaskStatusFilter =
| 'all'
| 'active'
| 'completed';
자주 발생하는 오류와 해결
- 검색 결과를 별도 state로 저장해 원본과 불일치
- trim을 하지 않아 공백 검색 결과가 이상함
- 대소문자를 통일하지 않음
- 필터 초기화 버튼에서 keyword만 지우고 status는 남음
- 원본 tasks를 sort 등으로 직접 변경
학습 완료 체크리스트
- 원본 state와 파생 데이터를 구분할 수 있습니다.
- 여러 filter 조건을 조합할 수 있습니다.
- 검색어를 정규화할 수 있습니다.
- 검색 로직을 Custom Hook으로 분리할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
검색어와 상태 필터를 추가합니다. 기존 Task, TaskAction 타입은 그대로 유지합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 23단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React Hook Form 에러 메시지 표시 문제 해결 validation 체크리스트의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/TaskFilter.tsx, src/components/TaskList.tsx, src/hooks/useTaskFilter.ts, src/types/task.ts. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/hooks/useTaskFilter.ts에 두 조건과 초기화를 모으고 TaskFilter.tsx의 입력과 App.tsx의 표시 목록에 연결합니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: 검색어와 완료 필터를 함께 적용했는데 결과가 0개인 이유를 어떻게 확인하나요?
컴포넌트 실습: step-23-search-filter에서 제목 일부를 검색하고 진행 중 필터를 선택한 뒤 완료 필터로 바꿉니다. 조건을 모두 초기화한 결과도 비교합니다.
예상 화면·상태: 검색어와 상태 조건의 교집합만 표시되고 결과가 없으면 빈 결과 문구가 나타나야 합니다. 원본 tasks는 필터 전 목록으로 남습니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 결과가 예전 값이면 별도 결과 state를 중복 저장했는지, 대소문자·공백 정규화와 필터 적용 순서를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 24회에서 통계 UI를 lazy와 Suspense로 분리합니다.
완료 코드 ZIP 다운로드 — step-23-search-filter
이전 체크포인트
step-22-form-validation-fix
이번 목표
검색어와 상태 필터를 추가합니다.
기존 Task, TaskAction 타입은 그대로 유지합니다.
실행 확인
- 일부 제목을 검색합니다.
- 완료/진행 중 필터를 바꿉니다.
- 검색어와 상태 조건을 동시에 적용합니다.
- 조건에 맞는 작업이 없을 때 빈 결과 메시지가 나오는지 확인합니다.
- 초기화 버튼이 두 조건을 모두 초기화하는지 확인합니다.
완료 체크포인트:
step-23-search-filter
전체 코드 뷰어의 모든 파일은 다운로드 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-23-search-filter",
"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 } 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';
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter } = 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} />
<TaskList tasks={visibleTasks} />
<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/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/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 { useState } from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatus] =
useState<TaskStatusFilter>('all');
const normalizedKeyword = keyword
.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 resetFilter = () => {
setKeyword('');
setStatus('all');
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
};
}
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 · 23/32 · TaskFlow 누적 실습
React TypeScript 검색 필터 만들기 상태 결과 목록 연결
React · 23 / 32 · 필수 학습 · 검색·필터
검색 필터를 만들 때 흔한 실수는 원본 목록과 검색 결과를 둘 다 state로 저장하는 것입니다. 검색 결과는 tasks, keyword, status에서 계산할 수 있으므로 별도 state로 복제하지 않아도 됩니다.
이 글에서는 여러 조건을 조합해 파생 목록을 만드는 방법을 설명하고 TaskFlow에 검색·상태 필터를 추가합니다.
이번 글에서 배우는 내용
- 검색어 state
- 완료 상태 필터
- 파생 데이터
- filter 조건 조합
- 대소문자와 공백 정규화
- 빈 결과 처리
- Custom Hook으로 검색 로직 분리
목차
다음 학습
다음 글에서는 초기 화면에 꼭 필요하지 않은 통계 UI를 React.lazy로 분리하고 Suspense fallback을 사용해 필요한 영역만 지연 로딩합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.