React TypeScript 검색 필터 만들기 상태 결과 목록 연결

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

React에서 검색어와 완료 상태를 함께 적용한 결과 목록을 어떻게 만드나요?

검색 결과는 원본 목록·검색어·완료 조건으로 계산할 수 있으므로 별도 state로 복제하지 않아도 됩니다. 검색어와 필터를 함께 적용한 교집합을 렌더링하고, 결과가 없을 때와 조건을 초기화했을 때를 나누어 표시합니다.

본문 근거: React Choosing the State Structure

독립 실습: 검색어와 완료 조건의 교집합 구하기

검색어와 상태 필터를 바꿔 표시 목록을 확인하세요.

예제 크게 보기 (새 탭)

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개

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기