프로젝트 스택 · 5장 라우팅 · 폼

5장 · 라우팅과 폼

화면을 이동시키는 TanStack Router? 와, 입력을 다루는 react-hook-form?(+ zod? 검증)를 익힙니다. 이 둘은 이 프로젝트의 모든 화면이 공통으로 쓰는 도구예요.

이 장의 코드는 대부분 "읽는 코드"예요

TanStack Router·react-hook-form 라이브러리는 오른쪽 연습장(iframe?)에서 실행할 수 없어요. 그래서 라이브러리 코드는 전부 정적 코드블록으로 보여 줍니다. 직접 실행해 보는 플레이그라운드는 폼의 본질("입력값을 어떻게 들고 있나")을 순수 React로 시연할 때만 나옵니다.

A. 라우팅 — 주소에 따라 화면 바꾸기

라우팅? 은 "지금 주소(URL)가 이거니까, 이 화면을 보여줘"를 정하는 일입니다. 주소창에 /app/users 가 찍히면 사용자 목록 화면이, /app/projects/42 가 찍히면 42번 프로젝트 상세 화면이 뜨는 식이에요.

URL = 건물 주소. /app/projects/42 는 "app 동 → projects 층 → 42호". 주소만 알면 어느 방(화면)인지 정해집니다. 라우터는 그 주소를 보고 맞는 방문을 열어 주는 안내원이에요.

A.1 파일 기반 라우팅 — 파일 위치가 곧 주소

이 프로젝트는 파일 기반 라우팅을 씁니다. src/routes/ 폴더 안의 파일 위치가 그대로 URL이 돼요. 따로 "이 주소는 이 화면" 하고 표를 만들 필요 없이, 파일을 알맞은 자리에 두기만 하면 됩니다.

파일 위치 (src/routes/ 아래)만들어지는 URL설명
app/users.lazy.tsx/app/users폴더·파일 이름이 경로가 됨
app/projects/index.tsx/app/projectsindex 는 그 폴더 자체
app/projects/$projectId.tsx/app/projects/42$ 로 시작 = 동적 파라미터

파일 이름이 $projectId.tsx 처럼 $ 로 시작하면, 그 자리는 "아무 값이나 들어올 수 있는 칸"이 됩니다. /app/projects/42, /app/projects/7 모두 같은 파일이 처리하고, 그 42·7파라미터로 꺼내 쓸 수 있어요(아래 useParams). .lazy.tsx 는 "그 화면에 들어갈 때 코드를 늦게 불러온다"는 뜻이라 지금은 몰라도 됩니다.

실제로 어디에 쓰나
  • 새 화면 추가 = 파일 추가 — "거래처 목록" 화면이 필요하면 src/routes/app/companies.lazy.tsx 파일 하나만 만들면 /app/companies 주소가 바로 생김
  • 목록 + 상세 한 쌍 — 목록은 app/projects/index.tsx, 상세는 app/projects/$projectId.tsx 로 나란히 둠
  • 동적 파라미터 $id$projectId·$userId 처럼 행마다 달라지는 상세 화면(프로젝트·사용자·주문 상세)
  • 화면 묶기app/ 폴더 아래 두면 전부 /app/... 로 시작 (로그인 뒤에만 보이는 화면 묶음)

화면을 옮기고 주소 속 값을 읽는 방법은 크게 셋입니다.

<Link to="..."> — 클릭으로 이동하는 링크

사용자가 눌러서 이동하는 메뉴·버튼은 <Link> 로 만듭니다. 평범한 <a> 와 달리 페이지 전체를 새로고침하지 않고 화면만 갈아끼워요.

import { Link } from "@tanstack/react-router"

function Menu() {
  return (
    <nav>
      <Link to="/app/users">사용자</Link>
      {/* 동적 파라미터는 params 로 채움 */}
      <Link to="/app/projects/$projectId" params={{ projectId: "42" }}>
        42번 프로젝트
      </Link>
    </nav>
  )
}

useNavigate — 코드로 이동하기

"저장이 끝나면 목록으로 이동" 처럼 어떤 동작 뒤에 이동해야 할 땐 useNavigate 로 함수를 받아 호출합니다. 주소창에 직접 주소를 치는 일을 코드로 한다고 보면 돼요.

import { useNavigate } from "@tanstack/react-router"

function ListView() {
  const navigate = useNavigate()

  // 행을 클릭하면 상세 페이지로 이동
  const gotoDetail = (row) => {
    navigate({
      to: "/app/projects/$projectId",        // 이동할 경로
      params: { projectId: String(row.id) }, // 동적 파라미터 채우기
    })
  }

  return <DataTable onRowClick={gotoDetail} />
}
실제로 어디에 쓰나
  • 저장 후 목록으로 — 등록·수정이 성공하면 입력 화면에서 목록 화면으로 돌려보내기
  • 행 클릭 시 상세로 — 표에서 한 줄을 누르면 그 항목의 상세 화면($projectId)으로 이동
  • 뒤로가기 — "취소" 버튼을 누르면 직전 화면으로: navigate({ to: ".." }) 또는 브라우저 뒤로
  • 탭/필터를 주소에 담기 — 검색 조건을 search 로 넘겨 새로고침해도 유지되게

useParams — 주소 속 값 읽기

도착한 화면에서 "내가 몇 번 프로젝트지?"를 알아야 데이터를 불러옵니다. 주소의 동적 파라미터($projectId 자리 값)는 useParams 로 꺼내요.

import { useParams } from "@tanstack/react-router"

function ProjectDetailPage() {
  // URL: /app/projects/42  →  projectId === "42"
  const { projectId } = useParams({ from: "/app/projects/$projectId" })

  // 주의: 파라미터는 항상 "문자열". 숫자로 쓰려면 변환한다
  const id = Number(projectId) // 42

  // 이 id 로 서버 데이터를 불러오면 됨 (4장 useQuery)
  // ...
}
파라미터는 항상 문자열

URL 에서 읽은 값은 42 처럼 보여도 사실 문자열 "42" 입니다. 숫자로 비교하거나 계산해야 하면 Number(projectId) 로 꼭 변환하세요. 안 그러면 "42" === 42false 가 되는 식의 버그가 납니다.

실제로 어디에 쓰나
  • 상세 화면에서 id 읽기 — 프로젝트 상세에서 projectId, 사용자 상세에서 userId 꺼내기
  • 읽은 id로 데이터 조회 — 그 iduseQuery 에 넘겨 해당 항목만 서버에서 가져오기 (4장)
  • id로 다시 이동 — 같은 화면에서 "이전/다음 항목" 버튼으로 다른 id 로 넘어가기
도구언제대표 코드
<Link>사용자가 눌러서 이동<Link to="/app/users">
useNavigate코드(저장 후 등)로 이동navigate({ to, params })
useParams주소 속 값 읽기useParams({ from: "..." })

B. 폼 — 입력을 다루기

폼(form)은 입력창이 모인 화면이에요(회사 등록, 사용자 수정 등). 폼의 일은 세 가지입니다: ① 값을 들고 있고, ② 규칙에 맞는지 검사하고(검증), ③ 제출 하기. 이걸 손으로 다 하면 번거로워서, react-hook-form? 이 대신 해 줍니다.

B.1 먼저 손으로 — 순수 React 제어 폼

라이브러리를 쓰기 전에, 폼의 본질부터 손으로 만들어 봅시다. 제어 컴포넌트? 란 입력값을 state? 가 들고 있는 입력창이에요. value 로 보여 주고 onChange 로 state 를 바꾸면, React 가 값을 통제합니다.

제어 폼 = 입력창이 칠판(state)을 그대로 비추는 거울. 사용자가 글자를 치면 칠판을 고치고, 칠판이 바뀌면 거울에 다시 보여 줍니다. 진짜 값은 늘 칠판에 있어요.

아래는 입력창 하나를 state 로 직접 관리하는 예제입니다. 타이핑하면 아래 미리보기가 실시간으로 바뀌어요.

입력창이 많아지면?

입력창 하나당 useState 하나, value 하나, onChange 하나가 필요해요. 필드가 10개면 이 짝이 10번 반복되고, 검증(빈 값 체크 등)·초기화·제출까지 손으로 다 짜야 합니다. 이 반복과 검증을 대신 해 주는 것이 react-hook-form 입니다.

B.2 react-hook-form — useForm · register · handleSubmit

useForm 하나로 폼 전체를 만듭니다. 핵심은 셋이에요.

  • defaultValues — 각 입력의 초기값(과 폼이 다룰 필드 목록)
  • register("name") — 입력창을 폼에 등록(값·onChange 를 폼이 대신 연결)
  • handleSubmit(onSubmit) — 검증을 통과하면 onSubmit 을 부름
import { useForm } from "react-hook-form"

function CompanyForm() {
  // ① 폼 생성 + 초기값. defaultValues 의 키 = 폼이 다루는 필드들
  const { register, handleSubmit } = useForm({
    defaultValues: {
      name: "",     // 회사명
      address: "",  // 주소
      note: "",     // 비고
    },
  })

  // ② 제출 핸들러 — 검증을 통과해야 호출됨. data 에 모든 값이 모여 옴
  const onSubmit = (data) => {
    console.log(data) // { name: "...", address: "...", note: "..." }
  }

  return (
    // ③ form 의 onSubmit 에 handleSubmit 을 건다
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* ④ 각 입력을 register 로 등록. 이름이 defaultValues 의 키와 같아야 함 */}
      <input {...register("name")} placeholder="회사명" />
      <input {...register("address")} placeholder="주소" />
      <textarea {...register("note")} placeholder="비고" />

      <button type="submit">저장</button>
    </form>
  )
}

순수 제어 폼과 비교해 보세요. 입력창마다 useState·value·onChange 를 쓰던 짝이 {'{...register("name")}'} 한 줄로 줄었습니다. 값 보관·연결을 폼이 대신해 줘요.

실제로 어디에 쓰나
  • 등록(생성) 폼 — 회사·사용자·프로젝트 새로 만들기. defaultValues 를 빈 값으로 두고 시작
  • 수정 폼에 기본값 채우기 — 불러온 기존 데이터를 defaultValues 로 넣어 입력칸을 미리 채움 (서버에서 늦게 오면 reset(data) 로 채움)
  • 검색 폼 — 목록 위의 검색 조건칸들도 같은 useForm 으로 묶어 한 번에 다룸
  • 제출 중 버튼 잠그기formState.isSubmitting 으로 저장 중엔 버튼 비활성화
가장 많이 틀리는 부분 ① — defaultValues 키 = 입력의 name

defaultValues 에 적은 register("...") 에 적은 이름정확히 같아야 값이 연결됩니다.

코드결과
✅ 올바름 defaultValues: { note: "" }register("note") 값이 연결됨
❌ 틀림 defaultValues: { tags: "" }register("note") note 는 초기값 없이 따로 놀고, tags 는 화면에 없는 유령 필드

키 이름과 입력의 name한 글자도 다르지 않게 맞추세요. (실무에서 tags 로 선언하고 note 로 입력해 값이 안 들어가는 버그가 흔합니다.)

가장 많이 틀리는 부분 ② — 제출은 한 번만

<form> 에 이미 onSubmit={handleSubmit(onSubmit)} 가 걸려 있으면, 제출 버튼은 type="submit" 하나로 충분합니다. type="submit" 버튼을 누르면 form 의 onSubmit 이 자동으로 불려요.

코드
✅ 올바름<button type="submit">저장</button>
❌ 이중 제출<button onClick={(e) => { e.preventDefault(); handleSubmit(onSubmit)() }}>

❌ 처럼 onClick 에서 preventDefaulthandleSubmit 을 또 부르면, form 의 onSubmit 과 겹쳐 제출이 꼬이거나 두 번 실행될 수 있어요. 둘 중 하나만 — 보통 form 에 걸고 버튼은 type="submit" 만 둡니다.

register 대신 Controller 가 필요할 때

register 는 일반 <input>·<textarea> 에 잘 맞습니다. 하지만 Shadcn UI 의 셀렉트·날짜 선택처럼 직접 만든 컴포넌트value/onChange 모양이 달라 register 가 안 붙어요. 그럴 땐 Controller(또는 control 을 받는 래퍼 컴포넌트)로 연결합니다. 이 프로젝트의 <TextField control={form.control} name="name" /> 가 그 래퍼예요. 모양만 기억하세요.

B.3 zod — 입력 규칙을 코드로 검증

"회사명은 1자 이상", "이메일 형식이어야 함" 같은 규칙(스키마)zod? 로 적고, zodResolver 로 폼에 연결하면 제출 시 자동으로 검사해 줍니다. 규칙에 맞지 않으면 onSubmit 이 호출되지 않고, formState.errors 에 메시지가 담겨요.

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"

// ① 규칙(스키마) 정의 — 키 이름은 폼 필드와 똑같이!
const schema = z.object({
  name: z.string().min(1, "회사명을 입력하세요"),
  address: z.string().optional(),          // 없어도 됨
  note: z.string().optional(),
})

// ② 스키마에서 타입을 자동 추출 (직접 타입을 또 적지 않아도 됨)
type CompanyForm = z.infer<typeof schema>
// → { name: string; address?: string; note?: string }

function CompanyForm() {
  const { register, handleSubmit, formState } = useForm<CompanyForm>({
    resolver: zodResolver(schema),          // ③ 검증 규칙 연결
    defaultValues: { name: "", address: "", note: "" }, // 키가 스키마와 일치
  })

  const onSubmit = (data: CompanyForm) => {
    // 여기 오면 이미 검증 통과 상태
    console.log(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} placeholder="회사명" />
      {/* 규칙 위반 시 메시지 표시 */}
      {formState.errors.name && <p>{formState.errors.name.message}</p>}

      <button type="submit">저장</button>
    </form>
  )
}

z.infer — 규칙 한 번, 타입 공짜

스키마를 만들면 z.infer<typeof schema> 로 그에 맞는 타입? 을 자동으로 뽑을 수 있어요. 검증 규칙과 타입을 한곳에서 관리하니, 규칙을 바꾸면 타입도 같이 따라옵니다. (이 <...> 꺾쇠 문법이 낯설면 4장의 제네릭 설명을 참고하세요. 지금은 "규칙에서 타입을 꺼낸다" 정도면 충분.)

실제로 어디에 쓰나
  • 필수값 검사 — 이름·회사명처럼 꼭 있어야 하는 값: z.string().min(1, "이름을 입력하세요")
  • 숫자 범위 — 수량·금액 같은 숫자가 정해진 범위 안인지: z.number().min(0).max(100)
  • 형식 검사 — 이메일·날짜 형식: z.string().email("이메일 형식이 아니에요")
  • 선택값 표시 — 비고처럼 비워도 되는 칸: z.string().optional()

B.4 폼을 더 다루는 도구 — useFieldArray · useWatch

여기부터는 조금 더 복잡한 폼에서 쓰는 도구입니다. 지금은 "이런 게 있고 어디에 쓰는지"만 알아 두면 충분해요. 실제로 쓰는 화면은 6장 화면 패턴에서 다룹니다.

useFieldArray — 줄을 추가/삭제하는 입력

입력 줄 수가 정해져 있지 않고 늘었다 줄었다 하는 폼에 씁니다. "추가" 버튼으로 줄을 더하고, "삭제" 버튼으로 줄을 빼는 식이에요. 각 줄은 폼 안에서 배열로 관리됩니다.

실제로 어디에 쓰나
  • 주문서 항목 목록 — 주문에 들어가는 품목을 한 줄씩 추가/삭제
  • 검수 항목 여러 줄 — 주문에 검수 항목을 필요한 만큼 추가
  • 담당자·연락처 여러 개 — 거래처에 담당자 줄을 늘려 가며 입력
  • 첨부·옵션 목록 — 줄마다 같은 모양의 입력칸 묶음을 반복

useWatch — 다른 입력값에 따라 화면 바꾸기

한 입력칸의 값을 실시간으로 지켜보다가, 그 값에 따라 다른 부분을 바꿀 때 씁니다. 값이 바뀌면 그 값을 보는 부분만 다시 그려져요.

실제로 어디에 쓰나
  • 대분류 → 중분류 — 대분류를 고르면 거기에 맞는 중분류 옵션만 보여 주기
  • 조건부 입력칸 — "기타"를 고르면 직접 입력칸이 나타나기
  • 실시간 계산 — 수량·단가를 입력하면 합계가 바로 바뀌기
  • 미리보기 — 입력한 이름이 화면 위 제목에 즉시 반영되기

이 밖에 useFormContext 는 폼을 여러 컴포넌트로 쪼갰을 때 자식에서 부모의 폼을 꺼내 쓰는 도구입니다. 이름만 기억해 두세요.

한눈에 정리

분류도구한 줄 요약
라우팅파일 기반 라우팅src/routes/ 의 파일 위치 = URL
<Link to>클릭으로 이동하는 링크
useNavigate코드로 이동 (저장 후 등)
useParams주소 속 값 읽기 (항상 문자열)
useForm폼 생성 (defaultValues)
register / handleSubmit입력 등록 / 검증 후 제출
zod + zodResolver규칙으로 검증, z.infer 로 타입
useFieldArray동적 배열·필드 감시 (6장에서)
이 장의 두 규칙만은 꼭

defaultValues = 입력의 name, 한 글자도 다르지 않게.
② form 에 onSubmit={handleSubmit(onSubmit)} 를 걸었으면 버튼은 type="submit" 하나로 끝. 이중 제출 금지.

다음 장에서는 지금까지 배운 라우팅·폼·상태관리를 모아 실제 화면(목록 + 상세 + 등록)을 만드는 패턴을 봅니다.