React 커스텀 훅 설계 상태 로직 컴포넌트 분리 기준

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

React 상태 로직은 언제 커스텀 훅으로 분리하고 state는 어떻게 공유되나요?

Custom Hook은 state·Effect 같은 상태 로직을 재사용하는 함수입니다. 순수 계산은 일반 함수로, UI는 컴포넌트로 남깁니다. 같은 Hook을 두 번 호출해도 로직을 재사용할 뿐 각 호출의 state가 자동으로 공유되지는 않습니다.

적용 조건·범위: 굳이 분리하지 않아도 되는 경우: - 한 줄짜리 단순 계산 - React Hook을 전혀 사용하지 않는 일반 유틸리티 - 한 컴포넌트에서만 쓰이고 이름을 붙여도 의미가 더 명확해지지 않는 코드

본문 근거: Reusing Logic with Custom Hooks

독립 실습: 로직을 공유해도 state는 각 호출에 남기

커스텀 Hook을 사용하는 카운터 동작을 확인하세요.

예제 크게 보기 (새 탭)

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

src/App.tsx 전체 코드

import { useState } from 'react';
function useCounter(initialValue: number) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(value => value + 1);
  return { count, increment };
}
function Counter({ label }: { label: string }) {
  const { count, increment } = useCounter(0);
  return <button onClick={increment}>{label}: {count}</button>;
}
export default function App() {
  return (
    <main>
      <Counter label="A" />
      <Counter label="B" />
    </main>
  );
}

직접 확인: A를 두 번 눌러도 B는 0입니다. 같은 custom Hook을 호출한다고 같은 state 저장소를 공유하지 않습니다.

변형 과제: useCounter에 reset 함수를 반환하고 A를 초기화해도 B에는 영향이 없는지 확인합니다.

가장 단순한 Custom Hook

이미 TaskFlow에는 useTaskContext가 있습니다.

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

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

  return value;
}

이 Hook을 이용해 작업 통계를 계산하는 useTaskStats를 만들 수 있습니다.

export function useTaskStats() {
  const { tasks } = useTaskContext();

  const totalCount = tasks.length;
  const completedCount = tasks.filter(
    (task) => task.completed,
  ).length;

  return {
    totalCount,
    completedCount,
    remainingCount:
      totalCount - completedCount,
  };
}

useTaskStats는 내부에서 useTaskContext라는 Hook을 호출하므로 Custom Hook입니다.

일반 함수와 Custom Hook을 구분합니다

React Hook을 전혀 사용하지 않는 순수 계산이라면 꼭 use로 시작할 필요가 없습니다.

export function getTaskStats(tasks: Task[]) {
  // 순수 계산
}

반대로 Context, state, Effect 등 React Hook 흐름을 조합한다면 Custom Hook으로 분리할 수 있습니다.

무엇을 Hook으로 분리할까

좋은 후보:
– 여러 컴포넌트가 반복하는 state + Effect 조합
– Context 접근과 도메인 계산을 묶은 로직
– UI와 직접 관계없는 상태 처리

굳이 분리하지 않아도 되는 경우:
– 한 줄짜리 단순 계산
– React Hook을 전혀 사용하지 않는 일반 유틸리티
– 한 컴포넌트에서만 쓰이고 이름을 붙여도 의미가 더 명확해지지 않는 코드

자주 발생하는 오류와 해결

조건문 안에서 Hook을 호출합니다

Hook은 컴포넌트 또는 Custom Hook의 최상위에서 호출합니다.

Hook을 사용하지 않는 일반 함수까지 use로 시작합니다

use 접두사는 React가 Hook 규칙을 적용하는 신호입니다. 단순 계산은 일반 함수 이름을 사용합니다.

하나의 Hook에 너무 많은 책임을 넣습니다

검색·네트워크·폼·모달을 하나의 Hook이 모두 관리하면 다시 거대한 로직 덩어리가 됩니다.

학습 완료 체크리스트

  • Custom Hook의 목적을 설명할 수 있습니다.
  • 다른 Hook을 조합한 Custom Hook을 만들 수 있습니다.
  • 일반 함수와 Custom Hook을 구분할 수 있습니다.
  • UI와 로직 책임을 분리할 수 있습니다.

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

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

TaskContext 접근과 작업 통계 계산을 useTaskStats로 묶습니다. TaskProvider, reducer, TaskForm, TaskList, TaskItem은 step-18 구조를 유지합니다.

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

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

TaskFlow 프로젝트 적용

TaskFlow 19단계 실행

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

예제 크게 보기 (새 탭)

시작 체크포인트: 이전: React useReducer와 Context 실습 작업 목록 상태 여러 컴포넌트 공유의 완료 상태에서 이어갑니다.

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

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

변경 순서: src/hooks/useTaskStats.ts에 Context 조회와 개수 계산을 모으고 App.tsx의 반복 계산을 훅 호출로 바꿉니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.

독자 질문과 직접 확인

질문: 미완료 작업 수 계산을 custom Hook으로 분리해도 각 컴포넌트의 상태는 어떻게 유지되나요?

컴포넌트 실습: step-19-custom-hook에서 작업을 추가·완료한 뒤 화면의 미완료 개수가 변하는지 봅니다. Hook이 반환하는 계산값과 Context에서 읽는 작업 목록의 관계를 따라가고, Hook 호출을 컴포넌트 최상위에 둡니다.

예상 화면·상태: UI의 수치는 18회와 같고 계산 로직의 위치만 달라지는 것이 목표입니다. custom Hook은 로직을 재사용하지만 별도 전역 상태를 자동 생성하지 않습니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.

오류 점검: Invalid hook call이 나오면 조건문·일반 함수 안에서 Hook을 호출했는지와 Hook 이름의 use 접두사를 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.

다음 학습: 20회에서 이미 TypeScript인 컴포넌트의 props API 경계를 다듬습니다.

완료 코드 ZIP 다운로드 — step-19-custom-hook

이전 체크포인트

step-18-reducer-context

이번 목표

TaskContext 접근과 작업 통계 계산을 useTaskStats로 묶습니다.

TaskProvider, reducer, TaskForm, TaskList, TaskItem은 step-18 구조를 유지합니다.

확인 포인트

이번 회차에서는 화면 기능이 크게 늘어나지 않습니다. App에서 Context 접근과 작업 통계 계산이 사라지고 useTaskStats라는 이름으로 의도가 드러나는지 확인합니다.

완료 체크포인트:

step-19-custom-hook

전체 코드 뷰어의 모든 파일은 다운로드 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-19-custom-hook",
  "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 { useTaskStats } from './hooks/useTaskStats';

function App() {

  const {
    totalCount,
    completedCount,
    remainingCount,
  } = useTaskStats();

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


  return (
    <main className="app">
      <Header />

      <NetworkStatus />

      <p>
        전체 {totalCount} · 완료 {completedCount} · 남음 {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/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 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 · 19/32 · TaskFlow 누적 실습

React 커스텀 훅 설계 상태 로직 컴포넌트 분리 기준

React · 19 / 32 · 필수 학습 · Custom Hook

여러 컴포넌트에서 state·Effect·Context 접근이 같은 목적을 위해 반복되면 Custom Hook으로 상태 로직을 분리합니다. React Hook을 사용하지 않는 순수 계산은 일반 함수로 두고, UI를 반환하는 책임은 컴포넌트에 남깁니다.

Custom Hook은 UI 자체가 아니라 Hook을 사용하는 상태 로직을 재사용하는 함수입니다. 이름은 use로 시작하고, 내부에서 다른 Hook을 호출할 수 있습니다.

이번 글에서 배우는 내용

  • Custom Hook이 무엇인지
  • Hook 이름이 use로 시작하는 이유
  • Context Hook을 조합하는 방법
  • UI와 상태 로직의 경계
  • 어떤 로직을 Hook으로 분리하면 좋은지
  • 과도한 추상화를 피하는 기준

목차

다음 학습

다음 글에서는 이미 TypeScript로 작성된 TaskFlow의 props 타입을 다시 검토합니다. JavaScript를 TypeScript로 바꾸는 글이 아니라, 컴포넌트 API가 필요한 값만 받도록 타입 경계를 다듬는 단계입니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

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

이 글을 쓴 해비

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

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기