Infrastructure React hook for SPA routing. Navigation API + URLPattern. No providers, no context.
npm install @budarin/use-routeuseRoute() можно строить любые , ‑подобные компоненты и layout‑ы под конкретный дизайн/UX, не привязываясь к чужому роутеру.
navigation.navigate(), back(), forward(), traverseTo()
historyIndex роута
useRoute()!
/ ; что рендерить, вы решаете в коде по pathname/params. Если важна именно декларативная вложенная структура маршрутов — используйте один из перечисленных роутеров.
bash
npm i @budarin/use-route
`
`typescript
import { useRoute, configureRoute } from '@budarin/use-route';
function App() {
const {
pathname,
params,
searchParams,
navigate,
go,
canGoBack
} = useRoute('/users/:id'); // опционально: паттерн для парсинга params
return (
Current: {pathname}
User ID: {params.id}
);
}
`
📖 API
$3
Формы вызова:
- useRoute() — без pattern и опций.
- useRoute(pattern) — только pattern (строка или PathMatcher).
- useRoute(pattern, options) — pattern и опции (например section).
- useRoute({ section: '/dashboard' }) — только опции, без pattern (раздел под глобальным base; pathname и navigate относительно раздела).
Параметры:
- pattern (опционально):* строка-шаблон пути (нативный URLPattern) или функция PathMatcher
Строка (URLPattern). Поддерживается:
- Именованные параметры — :name (имя как в JS: буквы, цифры, _). Значение сегмента попадает в params[name].
- Опциональные группы — { ... }?: часть пути можно сделать необязательной. Один паттерн покрывает пути разной глубины; в params только те ключи, для которых есть сегмент в URL.
- Wildcard — *: совпадает с «хвостом» пути; в params не попадает (числовые ключи из groups отфильтрованы).
- Regexp в параметре — :name(регулярка) для ограничения формата сегмента (например только цифры). В params по-прежнему строка.
`typescript
useRoute('/users/:id');
useRoute('/elements/:elementId/*/:subElementId'); // wildcard
// Опциональные группы
useRoute('/users/:id{/posts/:postId}?');
// Ограничение формата параметра (regexp)
useRoute('/blog/:year(\\d+)/:month(\\d+)');
// Функция-матчер (иерархия, кастомный разбор)
const matchPost = (pathname: string) => ({ matched: pathname.startsWith('/posts/'), params: {} });
useRoute(matchPost);
`
Полный синтаксис URLPattern: URL Pattern API (MDN), WHATWG URL Pattern.
PathMatcher — функция, которую можно передать вместо строки, когда одного URLPattern недостаточно (иерархия сегментов, кастомная валидация, разбор через split или RegExp). Хук вызывает её с текущим pathname и подставляет возвращённые matched и params в состояние.
- Параметр: pathname: string — текущий pathname (без origin и query).
- Возвращаемый тип: { matched: boolean; params: RouteParams }.
matched — совпал ли путь с вашей логикой; params — объект «имя параметра → значение сегмента» (тип RouteParams = Record).
- Где использовать: иерархические маршруты (например, postId только при наличии userId), пути с жёстким порядком сегментов, кастомные правила, которые не выразить одним URLPattern.
- options (опционально)
- section: путь раздела под глобальным base (например /dashboard). navigate(to) по умолчанию добавляет к путям полный префикс (base + section). Комбинируется с глобальным base из configureRoute, не заменяет его. В компонентах раздела вызывайте useRoute({ section: '/dashboard' }) и работайте с путями относительно раздела.
Возвращает:
`typescript
{
// Текущее состояние
location: string;
pathname: string;
searchParams: URLSearchParams; // только чтение, не мутировать
params: Record;
historyIndex: number;
state?: unknown; // state текущей записи истории (getState() / history.state)
matched?: boolean; // true/false при переданном pattern, иначе undefined
// Навигация
navigate: (to: string | URL, options?) => Promise; // Navigation API; same-document при перехвате navigate + intercept()
back: () => void;
forward: () => void;
go: (delta: number) => void;
replace: (to: string | URL, options?: NavigateOptions) => Promise;
updateState: (state: unknown) => void; // обновить state текущей записи без навигации
canGoBack: (steps?: number) => boolean;
canGoForward: (steps?: number) => boolean;
}
`
Опции методов navigate и replace (один интерфейс NavigateOptions):
`typescript
{
history?: 'push' | 'replace' | 'auto'; // по умолчанию из configureRoute или 'auto'
state?: unknown; // опциональные данные перехода (только подсказки для UX); подробнее — раздел про state ниже
base?: string | null | false; // полная подстановка префикса: любое falsy ('' | '/' | null | false | undefined при наличии ключа) — без префикса; иначе — полный путь (напр. '/auth')
section?: string | null | false; // переопределение секции: любое falsy ('' | null | false | undefined при наличии ключа) — корень приложения (только global base); '/path' — другая секция
}
`
- state — произвольные данные, которые вы передаёте вместе с переходом в navigate(to, { state }) или replace(to, { state }). Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы): страница должна корректно работать и при заходе по прямой ссылке без state. Подробно: см. ниже «Параметр state: когда добавлять в историю».
replace(to, options?) — то же, что navigate(to, { ...options, history: 'replace' }). Опции те же, что у navigate (state, base, section); поле history игнорируется (всегда замена записи).
updateState(state) — обновляет state текущей записи истории без навигации. Подписчики хука получают новый state; URL не меняется, новая запись в истории не создаётся. Удобно для черновика формы, позиции скролла и т.п.
Параметр state: когда добавлять в историю и какое состояние можно передавать
Многие разработчики ни разу не используют state при переходах — это нормально. State нужен только в узких сценариях. Ниже — когда его стоит добавлять, какое состояние можно передавать и какое нельзя, чтобы не было недопониманий.
Что такое state и откуда он берётся. State — это произвольные данные, которые вы передаёте в navigate(to, { state }) или replace(to, { state }). Они сохраняются в записи истории (Navigation API) и доступны в хуке через поле state в возвращаемом объекте. Важно: state появляется только при программном переходе (ваш вызов navigate/replace). Если пользователь попал на тот же URL извне — ввёл адрес в строке, перешёл по букмарку, по ссылке с другого сайта, обновил страницу — для этой записи истории state нет. Поэтому поведение страницы не должно от state критически зависеть.
Когда нужно добавлять state в историю. Добавляйте state только когда вы хотите передать «подсказку» для целевой страницы, которая улучшает UX при программном переходе, но не обязательна для корректной работы:
- Подсказка для скролла — уходя со страницы списка, сохраняете в state позицию скролла; по «Назад» можно вернуть пользователя на то же место. Если зашли по прямой ссылке — state нет, показываете список с начала.
- Подсказка «откуда пришли» — переход с поиска на карточку: в state передаёте { from: 'search', highlight: 'keyword' }; на карточке можно подсветить слово. При заходе по прямой ссылке подсветки нет — страница остаётся корректной.
- Опциональный префилл формы — переход «Редактировать» из списка: в state передаёте черновик; на странице редактирования при наличии state подставляете его, при отсутствии — грузите данные по id из URL/сервера.
- Черновик формы на текущей странице — при вводе в форму можно периодически сохранять черновик в state текущей записи через updateState(draft); по «Назад» пользователь вернётся на эту страницу с тем же state, и вы подставите черновик. Без state показываете пустую форму или грузите данные по URL.
- Источник перехода (аналитика, UI) — в state передаёте { source: 'dashboard' }; целевая страница может отправить это в аналитику или чуть изменить UI. При заходе по ссылке без state считаете источник «прямой» или «unknown».
Какое state можно передавать. Только то, что является опциональным улучшением: подсказки для скролла, флаги «откуда пришли», опциональный префилл, метаданные для аналитики. Правило: целевая страница должна корректно работать и без state (при прямом заходе по URL).
Какое state нельзя передавать. Не используйте state для того, без чего страница работает некорректно или неполно:
- Обязательные данные страницы — например, результаты поиска только из state. При переходе по ссылке /search?q=foo state нет — экран пустой. Результаты должны браться из query или сервера.
- То, что должно быть в URL (шаринг, букмарк) — state не попадает в URL. Если поведение страницы должно воспроизводиться по одной ссылке — используйте pathname и query, не state.
- Авторизация, права, критичные данные — не опирайтесь на state: пользователь может открыть URL напрямую. Проверки — по сессии/серверу.
- Основной контент страницы — что показывать, определяется URL и данными с бэкенда. State — только для подсказок, не источник правды.
Итог. State в истории — опциональный инструмент для «передать что-то вместе с переходом», когда это улучшение, а не требование. Если сомневаетесь — можно не использовать; в большинстве приложений достаточно pathname, query и запросов к API.
$3
`typescript
configureRoute({
urlCacheLimit?: number,
defaultHistory?: 'auto' | 'push' | 'replace',
logger?: Logger,
base?: string,
initialLocation?: string
});
`
Глобальная настройка один раз при старте приложения**. Повторная инициализация не предусмотрена: вызывайте configureRoute только при старте; смена конфига в рантайме не поддерживается (внутренние кэши и состояние не сбрасываются).
`typescript
configureRoute({
urlCacheLimit: 50, // лимит LRU-кэша URL (по умолчанию 50)
defaultHistory: 'replace', // history по умолчанию для всех navigate()
base: '/app', // базовый путь: pathname без base, navigate(to) добавляет base к относительным путям
logger: myLogger, // логгер (дефолт: console)
initialLocation: request.url, // для SSR: начальный URL при рендере на сервере (нет window)
});
`
- defaultHistory (по-умолчанию - 'auto') - глобально задает поведение записи истории при навигации при помощи методов navigate и replace
- base (по-умолчанию - '/') — нужен только когда приложение располагается не в корне домена, а по подпути. Пример: сайт https://example.com/ — корень; ваше приложение отдаётся по https://example.com/app/, то есть все его маршруты физически лежат под путём /app. В этом случае задайте base: '/app': navigate('/dashboard') переходит на /app/dashboard. Если приложение в корне домена (https://example.com/), глобальный base задавать не нужно — префикс не используется.
- logger (по-умолчанию - console) — объект с методами debug, info, warn, error. Если не указан — используется console.
- initialLocation (по-умолчанию - '/') — при SSR (нет window) хук не знает URL запроса. Задайте initialLocation: request.url (или полный URL страницы) один раз перед рендером запроса — тогда pathname и searchParams будут соответствовать запросу. На клиенте не используется. По умолчанию задавать не нужно: если на SSR initialLocation не задан, используется '/' (pathname и searchParams для корня).
$3
Метод для очистки кэшей (тесты, смена окружения)
🛠 Примеры
$3
`tsx
import { useRoute } from '@budarin/use-route';
function BasicNavigationExample() {
const { pathname, navigate } = useRoute();
return (
Текущий путь: {pathname}
);
}
`
$3
`tsx
import { useRoute } from '@budarin/use-route';
function ParamsExample() {
const { params, pathname, navigate } = useRoute('/users/:id');
return (
Pathname: {pathname}
User ID из params: {params.id ?? '—'}
);
}
`
$3
`tsx
import { useRoute } from '@budarin/use-route';
function SearchParamsExample() {
const { searchParams, navigate, pathname } = useRoute('/posts');
const pageParam = searchParams.get('page') ?? '1';
const currentPage = Number.parseInt(pageParam, 10) || 1;
return (
Путь: {pathname}
Страница: {currentPage}
type="button"
onClick={() => navigate(/posts?page=${currentPage - 1})}
disabled={currentPage <= 1}
>
Пред. страница
/posts?page=${currentPage + 1})}>
След. страница
);
}
`
$3
`tsx
import { useRoute } from '@budarin/use-route';
function HistoryExample() {
const { go, back, forward, canGoBack, canGoForward } = useRoute();
return (
);
}
`
$3
`tsx
import { useRoute } from '@budarin/use-route';
function PushReplaceExample() {
const { navigate, replace, pathname } = useRoute();
return (
Текущий путь: {pathname}
);
}
`
$3
State текущей записи истории доступен в хуке как state. Установить state при переходе — через опцию state в navigate или replace. Обновить state текущей страницы без перехода — updateState(state). Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы); страница должна корректно работать и при заходе по прямой ссылке без state.
`tsx
import { useRoute } from '@budarin/use-route';
function StateExample() {
const { state, navigate, updateState, pathname } = useRoute();
return (
Текущий путь: {pathname}
State записи: {state != null ? JSON.stringify(state) : '—'}
type="button"
onClick={() => navigate('/detail', { state: { from: 'list', scrollY: 100 } })}
>
Перейти с state
);
}
`
$3
`tsx
import { useRoute } from '@budarin/use-route';
function MatchedExample() {
const { pathname, matched, params } = useRoute('/users/:id');
return (
Pathname: {pathname}
Pattern /users/:id совпал: {matched === true ? 'да' : 'нет'}
{matched === true ? (
User ID: {params.id}
) : (
Это не страница пользователя (path не совпал с /users/:id).
)}
);
}
`
$3
Удобно, когда один URLPattern или простой regex не справляется: иерархия (например, postId только вместе с userId), кастомная валидация, разный порядок сегментов. Ниже — матчер для /users/:userId и /users/:userId/posts/:postId: два параметра, причём postId допустим только после литерала posts и только при наличии userId.
`tsx
import { useRoute, type PathMatcher } from '@budarin/use-route';
const matchUserPosts: PathMatcher = (pathname) => {
const segments = pathname.split('/').filter(Boolean);
if (segments[0] !== 'users' || !segments[1]) return { matched: false, params: {} };
const params: Record = { userId: segments[1] };
if (segments[2] === 'posts' && segments[3]) {
params.postId = segments[3];
}
return { matched: true, params };
};
function UserPostsExample() {
const { pathname, matched, params } = useRoute(matchUserPosts);
if (!matched) return null;
return (
Путь: {pathname}
User ID: {params.userId}
{params.postId && Post ID: {params.postId}
}
);
}
`
$3
Когда приложение располагается не в корне домена, а по подпути (например https://example.com/app/ — все маршруты под /app), задайте в конфиге base: '/app'. Тогда navigate(to) добавляет base к относительным путям. Для одноразового перехода «вне» этого пути (например на /login) используйте опцию base в navigate или replace: navigate('/login', { base: '' }).
`tsx
import { useRoute, configureRoute } from '@budarin/use-route';
configureRoute({ base: '/app' });
function AppUnderBase() {
const { pathname, navigate } = useRoute();
return (
Текущий путь: {pathname}
);
}
`
$3
Когда у приложения несколько разделов по своим подпутям (/dashboard, /admin, /auth), в компонентах раздела задайте section: вызовите useRoute({ section: '/dashboard' }). Тогда navigate(to) по умолчанию добавляет полный префикс (base + section).
Переход в корень приложения (без секции): navigate('/', { section: '' }).
Переход «вне» приложения: navigate('/login', { base: '' }).
`tsx
import { useRoute } from '@budarin/use-route';
const DASHBOARD_BASE = '/dashboard';
function DashboardSection() {
// Section для раздела: pathname и navigate относительно /dashboard (под глобальным base, если задан)
const { pathname, navigate } = useRoute({ section: DASHBOARD_BASE });
return (
{/ При URL /dashboard/reports pathname === '/reports' /}
Раздел Dashboard. Путь: {pathname}
{/ Переход в корень приложения (без секции) или на главную /}
);
}
`
$3
При рендере на сервере нет window, поэтому хук не знает URL запроса. Задайте initialLocation в конфиге один раз перед рендером запроса (например request.url) — тогда pathname и searchParams будут соответствовать запросу. На клиенте initialLocation не используется.
`tsx
// Серверный обработчик (псевдокод: Express, Fastify, Next и т.д.)
import { configureRoute } from '@budarin/use-route';
import { renderToStaticMarkup } from 'react-dom/server';
import { App } from './App';
function handleRequest(req, res) {
// Один раз перед рендером этого запроса
configureRoute({ initialLocation: req.url });
const html = renderToStaticMarkup( );
res.send(html);
}
// В App компоненты используют useRoute() — на сервере получают pathname/searchParams из initialLocation
function App() {
const { pathname, searchParams } = useRoute();
return (
Pathname: {pathname}
Query: {searchParams.toString()}
);
}
`
$3
Минимальный пример компонента-ссылки поверх хука. Можно взять за основу и развивать под себя: активное состояние, префетч, аналитика, стили.
`tsx
import { useRoute } from '@budarin/use-route';
import { useCallback, type ComponentPropsWithoutRef } from 'react';
interface LinkProps extends ComponentPropsWithoutRef<'a'> {
to: string;
replace?: boolean;
}
function Link({ to, replace = false, onClick, ...props }: LinkProps) {
const { navigate } = useRoute();
const handleClick = useCallback(
(e: React.MouseEvent) => {
onClick?.(e);
if (!e.defaultPrevented) {
e.preventDefault();
navigate(to, { history: replace ? 'replace' : 'push' });
}
},
[navigate, to, replace, onClick]
);
return ;
}
// Использование:
// Посты
// Профиль (replace)
`
🧪 Тестирование
Для unit‑тестов в jsdom‑окружении есть вспомогательный helper setupTestNavigation из entrypoint‑а @budarin/use-route/testing. Он настраивает window.location и window.navigation под указанный URL и возвращает функцию для отката.
`ts
import { beforeEach, afterEach, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useRoute } from '@budarin/use-route';
import { setupTestNavigation } from '@budarin/use-route/testing';
let restoreNavigation: () => void;
beforeEach(() => {
restoreNavigation = setupTestNavigation({ initialUrl: 'http://localhost/users/123' });
});
afterEach(() => {
restoreNavigation();
});
it('читает pathname и params из Navigation API', () => {
const { result } = renderHook(() => useRoute('/users/:id'));
expect(result.current.pathname).toBe('/users/123');
expect(result.current.params).toEqual({ id: '123' });
});
`
⚙️ Установка
`bash
npm i @budarin/use-route
pnpm add @budarin/use-route
yarn add @budarin/use-route
`
TypeScript: типы включены.
tsconfig.json (рекомендуется):
`json
{
"compilerOptions": {
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"jsx": "react-jsx"
}
}
`
⚛️ React
Пакет рассчитан на React 18+: внутри используется useSyncExternalStore и поведение concurrent rendering, которые официально поддерживаются начиная с React 18.
🌐 Браузеры и Node.js
Пакет работает только со средами, где есть Navigation API и URLPattern. Ограничивающие требования — версии ниже; без них хук не запустится.
| API | Chrome/Edge | Firefox | Safari | Node.js |
| -------------- | ----------- | ------- | ------ | ------- |
| Navigation API | 102+ | 109+ | 16.4+ | — |
| URLPattern | 110+ | 115+ | 16.4+ | 23.8+ |
🎛 Под капотом
- Navigation API: подписка на события navigate, currententrychange; для same-origin навигации — перехват navigate и вызов event.intercept()
- useSyncExternalStore на navigation события
- Map для O(1) поиска historyIndex
- URLPattern для :params
- Кэш LRU parsed URL (настраиваемый лимит)
- Кэш compiled patterns
- SSR-safe (checks typeof window`)