← 목록으로

URL로 상태 관리하기 — Tanstack Router와 Next.js

2026-07-09

상태 관리, 꼭 라이브러리가 필요할까?

React에서 상태를 관리할 때 주로 useState, Context API, 필요에 따라 Zustand 같은 라이브러리를 사용한다. 사용자 상호작용이 많이 발생하는 웹 애플리케이션이라면 상태 관리가 필수적이겠지만, 대부분의 프로젝트에서는 상태를 URL로 위임하여 URL에서 직접 가져오는 방식이 더 적합할 수 있다.

이 글에서는 그 방법을 Tanstack Router 위주로 정리한다.


Tanstack Router의 validateSearch

Tanstack Router로 URL 상태를 관리할 때 validateSearch 옵션으로 타입 안정성을 확보할 수 있다.

export const Route = createFileRoute("/page/")({
  validateSearch: (search) => ({
    q: String(search.q ?? ""),
    searchType: search.searchType as "email" | "userName",
  }),
  component: Page,
});

function Page() {
  const { q, searchType } = useSearch({ from: Route.id });
  // ...
}

Props 드릴링 없이 컴포넌트 간 상태가 공유되어 useSearch()로 바로 접근할 수 있으며, 팀원이나 다른 사람에게 링크를 전달할 때 동일한 화면을 재현할 수 있다.

다만 searchType에 의도하지 않은 값이 입력될 수 있다. 예를 들어 searchType=abc 같은 값이 들어오면 abc가 그대로 반환된다. 이를 보완하기 위해 유효성 검사, 타입 변환, 이상한 값은 지정된 타입으로 변경하는 처리를 해주어야 한다.


수동으로 유효성 검사하기

type SearchType = "email" | "userName";

type TestSearch = {
  q: string;
  searchType: SearchType;
};

const SEARCH_TYPES: SearchType[] = ["email", "userName"];

export const Route = createFileRoute("/page/")({
  validateSearch: (search: Record<string, unknown>): TestSearch => {
    const q = typeof search.q === "string" ? search.q : "";
    const searchType = SEARCH_TYPES.includes(search.searchType as SearchType)
      ? (search.searchType as SearchType)
      : "email";
    return { q, searchType };
  },
  component: Page,
});

허용된 값만 통과시키고, 그 외의 값은 기본값('email')으로 대체한다.


Zod와 결합하기 (권장)

zod 라이브러리를 사용하면 좀 더 간단하게 표현할 수 있는데, Tanstack Router에서도 Zod와 결합하여 사용하는 것을 권장하고 있다.

zod v3 에서는 어댑터를 활용한다.

import { zodValidator } from "@tanstack/zod-adapter";

const Schema = z.object({
  q: z.string().default(""),
  searchType: z.enum(["email", "userName"]).default("email"),
});

export const Route = createFileRoute("/page/")({
  validateSearch: zodValidator(Schema),
  component: Page,
});

zod v4 에서는 어댑터 없이 사용이 가능하다.

export const Schema = z.object({
  q: z.string().default(""),
  searchType: z.enum(["email", "userName"]).default("email"),
});

export const Route = createFileRoute("/page/")({
  validateSearch: Schema,
  component: Page,
});

RHF와 함께 쓰기

검색 폼 같은 사용자 입력값은 자주 변경되는 데이터이기 때문에 쿼리스트링만으로 관리하기엔 어려움이 있다. 이럴 때 RHF(React Hook Form) 를 사용하여 사용자가 입력하는 동안에는 RHF가 담당하고, 확정된 값만 쿼리스트링으로 관리하면 리렌더링을 최소화할 수 있다.

export const Schema = z.object({
  q: z.string().default(""),
  searchType: z.enum(["email", "userName"]).default("email"),
});

export const Route = createFileRoute("/page/")({
  validateSearch: Schema,
  component: Page,
});

function Page() {
  const { q, searchType } = useSearch({ from: Route.id });

  const form = useForm<SearchFormValues>({
    values: { q, searchType },
  });

  // ...
}

Next.js에서는 nuqs

Next.js에서는 nuqs 라이브러리를 사용하면 위와 같은 기능을 사용할 수 있다. 브라우저 주소창에 잘못된 값을 입력하면 해당 값은 그대로 노출되지만, 반환 값은 parse된 타입으로 지정해준다.

"use client";

import { useQueryStates, parseAsString, parseAsStringLiteral } from "nuqs";

const SEARCH_TYPES = ["email", "userName"] as const;
type SearchType = (typeof SEARCH_TYPES)[number];

export function useSearchFilters() {
  return useQueryStates({
    q: parseAsString.withDefault(""),
    searchType: parseAsStringLiteral(SEARCH_TYPES).withDefault("email"),
  });
}

결론

필자는 현업에서 중요하지 않은 정보들은 url로 관리하여 useState, useEffect 사용을 최소화 하고 있다. 단순한 웹 페이지의 경우 useState 사용을 거의 안하고 있으며, 사용자의 중요한 정보는 context API 또는 Zustand 라이브러리를 사용해 노출되지 않도록 설정하고 있다. 글자수가 2000자로 제한되어있어 대용량 데이터를 담기에는 부족하지만, 이런 경우 보통 데이터 key 값을 활용해 props 로 전달하거나 전역 상태로 뺴버리곤 한다. 공유·복원이 필요한 "화면 상태"(검색어, 필터, 페이지네이션, 탭 등) 는 URL로 관리하고, 자주 바뀌거나 민감하거나 복잡한 상태 는 기존 상태 관리 도구에 맡기는 조합이 가장 실용적이라 생각한다.