@astral/mobx-router
v1.8.1
Published
<p align="center"> <img style="background-color: white; border-radius: 38px;" width="110px" height="110px" src="https://storage.yandexcloud.net/astral-frontend/mobx-router-logo.png"> </p>
Readme
@astral/mobx-router
Абстрактный реактивный router service, позволяющий использовать один интерфейс взаимодействия для разных роутеров:
- react-router v6 и v7
- nextjs router
Краткий обзор
Содержание
- Getting Started
- Определение routes
- RouteParams
- Навигация
- Работа с SearchParams
- Блокировка маршрутов (blocker)
- MatchPath - Сопоставление маршрутов
- Location
- SubscribeRoute
- Debug
- Тестирование
Getting Started
React Router v6/v7
Установка
npm install @astral/mobx-router @astral/mobx-router-react mobx react-routerПодготовка
Для работы с react-router v6/v7 необходимо использовать
DataRouter. Ниже описан пример настройкиВсе импорты должны быть из
react-router, а не изreact-router-dom
// ✅
import { createBrowserRouter, useLocation } from 'react-router';
// ❌
import { createBrowserRouter, useLocation } from 'react-router-dom';Настройка
Важно: Для работы с react-router v6/v7 необходимо использовать DataRouter.
shared/services/Router
import { MobxRouter } from '@astral/mobx-router';
import { ReactRouterProvider } from '@astral/mobx-router-react';
export const reactRouterProvider = new ReactRouterProvider();
const routesConfig = {
home: MobxRouter.defineRoute({
pattern: '/home',
}),
user: MobxRouter.defineRoute({
pattern: '/user/:id',
generatePath: (params: { id: string }) => `/user/${params.id}`,
}),
profile: MobxRouter.defineRoute({
pattern: '/profile/:userId?',
generatePath: ({ userId }: { userId?: string }) => userId ? `/profile/${userId}` : '/profile' ,
}),
};
export const router = new MobxRouter(reactRouterProvider, routesConfig);
// Необходим для инжектирования в MobX store, чтобы в типах были routes приложения
export type Router = typeof router;application/routes.tsx
import { lazy, Suspense } from 'react';
import { ProviderAdapter } from '@astral/mobx-router-react';
import { createBrowserRouter, Outlet } from 'react-router';
import { MainLayout } from '#modules/layout';
import { ContentState, reactRouterProvider, router } from '#shared';
const IndexPage = lazy(() => import('./pages/index'));
const HomePage = lazy(() => import('./pages/home'));
const UserPage = lazy(() => import('./pages/user'));
const ProfilePage = lazy(() => import('./pages/profile'));
export const browserRouter = createBrowserRouter([
{
element: (
<>
<ProviderAdapter provider={reactRouterProvider} />
<MainLayout>
<Suspense
fallback={<ContentState isLoading>loading...</ContentState>}
>
<Outlet />
</Suspense>
</MainLayout>
</>
),
children: [
{
path: '/',
element: <IndexPage />,
},
{
path: router.routes.home.pattern,
element: <HomePage />,
},
{
path: router.routes.user.pattern,
element: <UserPage />,
},
{
path: router.routes.profile.pattern,
element: <ProfilePage />,
},
],
},
]);application/app
import { ProviderAdapter } from '@astral/mobx-router-react';
import { RouterProvider as ReactRouterProvider } from 'react-router/dom';
import { browserRouter } from './routes';
const App = () => {
return (
<ReactRouterProvider router={browserRouter} />
);
};Next.js (Pages Router)
Поддерживает только pages routing
Установка
npm install @astral/mobx-router @astral/mobx-router-nextjs-pages mobxНастройка
shared/services/Router
import { MobxRouter } from '@astral/mobx-router';
import { NextjsPagesRouterProvider } from '@astral/mobx-router-nextjs-pages';
const provider = new NextjsPagesRouterProvider();
const routesConfig = {
home: MobxRouter.defineRoute({
pattern: '/home',
}),
user: MobxRouter.defineRoute({
pattern: '/user/[id]',
generatePath: (params: { id: string }) => `/user/${params.id}` ,
}),
profile: MobxRouter.defineRoute({
pattern: '/profile/[[userId]]',
generatePath: ({ userId }: { userId?: string }) => userId ? `/profile/${userId}` : '/profile' ,
}),
};
export const router = new MobxRouter(provider, routesConfig);
// Необходим для инжектирования в MobX store, чтобы в типах были routes приложения
export type Router = typeof router;pages/_app.tsx
import { ProviderAdapter } from '@astral/mobx-router-nextjs-pages';
import type { AppProps } from 'next/app';
import { provider } from '#shared/services/Router';
function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<ProviderAdapter provider={provider} />
<Component {...pageProps} />
</>
);
}
export default MyApp;Использование
После настройки вы можете использовать роутер для навигации:
// Простой переход
router.navigate('/dashboard');
// Переход с заменой истории
router.navigate('/login', { replace: true });Или подписываться на изменения роутера в mobx:
import { makeAutoObservable } from 'mobx';
import type { Router } from '#shared';
class Store {
constructor(private readonly _router: Router) {
makeAutoObservable(this);
}
get isAdminPage() {
return this._router.location.pathname.startsWith('/admin');
}
}Определение routes
После создания роутера необходимо определить маршруты приложения:
- Для статических маршрутов достаточно указать только
pattern. Он будет использоваться как pathname для переходов - Для динамических маршрутов с использованием routeParams необходимо определять
generatePath. ЕслиgeneratePathне будет принимать параметров, возникнет исключение
import { MobxRouter } from '@astral/mobx-router';
// Определяем конфигурацию routes
const routesConfig = {
// Для статичных путей без параметров достаточно указать pattern
home: MobxRouter.defineRoute({
pattern: '/home', // pathname будет '/home'
}),
// Для путей с параметрами рекомендуется указывать generatePath
user: MobxRouter.defineRoute({
pattern: '/user/:id',
generatePath: (params: { id: string }) => `/user/${params.id}` ,
}),
profile: MobxRouter.defineRoute({
pattern: '/profile/:userId?',
generatePath: ({ userId }: { userId?: string }) => userId ? `/profile/${userId}` : '/profile' ,
}),
};
// Создаем роутер с конфигурацией
export const router = new MobxRouter(provider, routesConfig);Навигация (navigate)
Интерфейс navigate аналогичен интерфейсу корневого navigate.
После определения routes вы можете использовать их для типобезопасной навигации:
// Простая навигация без параметров
router.routes.home.navigate();
// Навигация с обязательными параметрами
router.routes.user.navigate({ params: { id: '123' } });
// Навигация с опциональными параметрами
router.routes.profile.navigate(); // без параметров
router.routes.profile.navigate({ params: { userId } }); // с параметрамиRouteParams
Каждый маршрут также предоставляет доступ к своим параметрам через свойство routeParams:
type Params = { id: string };
const routesConfig = {
user: Router.defineRoute({
pattern: '/user/:id',
generatePath: (params: Params) => `/user/${params.id}`,
}),
};
// Получение параметров конкретного маршрута
router.routes.user.routeParams; // { id: '123' }В примере выше router.routes.user.routeParams будет соответствовать типу:
{ id: string } | undefinedundefined, если маршрут не активен.
Проверка активности route (isActive)
Каждый route предоставляет свойство isActive, которое позволяет определить, является ли данный route активным в текущий момент. Это свойство является MobX observable и автоматически обновляется при изменении URL.
Синтаксис
router.routes.routeName.isActive: booleanПример
import { MobxRouter } from '@astral/mobx-router';
const routesConfig = {
user: MobxRouter.defineRoute({
pattern: '/user/:id',
generatePath: ({ id }: { id: string }) => `/user/${params.id}`,
}),
};
export const router = new MobxRouter(provider, routesConfig);
...import { makeAutoObservable } from 'mobx';
class SidebarStore {
constructor(private readonly _router: Router) {
makeAutoObservable(this);
}
get isUserRouteActive() {
return this._router.routes.user.isActive;
}
}Генерация ссылок (generateHref)
Для создания ссылок на route используйте метод generateHref:
const profileLink = router.routes.profile.generateHref({
userId: '456',
}); // /profile/456
// Использование в компонентах
<Link to={profileLink}>Мой профиль</Link>Генерация ссылки с searchParams
const userLink = router.routes.user.generateHref(
{ id: '123' },
{ searchParams: { page: 1 } },
); // /user/123?page=1
// Использование в компонентах
<Link to={userLink}>Мой профиль</Link>;RouteParams
Роутер предоставляет доступ к параметрам текущего активного маршрута через свойство routeParams:
const routesConfig = {
user: Router.defineRoute({
pattern: '/user/:id',
generatePath: (params: { id: string }) => `/user/${params.id}`,
}),
};
const router = new Router(provider, routesConfig);
// Получение параметров текущего активного маршрута
console.log(router.routeParams); // { id: '123' }Навигация
Метод navigate
Основной метод для навигации между страницами приложения.
Синтаксис
router.navigate(to: string | Location, options?: NavigateOptions)Параметры
to(string | Location) - путь для перехода (строка или объект)options(NavigateOptions, опционально) - дополнительные параметры навигации
Тип NavigateOptions
type NavigateOptions = {
/**
* Позволяет сделать переход без сохранения в history
*/
replace?: boolean;
/**
* @sse https://developer.mozilla.org/en-US/docs/Web/API/History/scrollRestoration
*/
scrollRestoration?: 'auto' | 'manual';
/**
* SearchParams в виде plain object для перехода
*/
searchParams?: SearchParams;
/**
* Дополнительные данные, которые будут переданы в `location.state`
* при навигации (поддерживается только в react-router).
*
* ⚠️ В Next.js Router этот параметр **не поддерживается**
*/
state?: unknown;
};Примеры использования
// Простой переход на страницу (строка)
router.navigate('/main');
// Переход с заменой текущей записи в истории браузера
router.navigate('/main', { replace: true });
// Переход с отключением автоматической прокрутки
router.navigate('/main', { scrollRestoration: 'manual' });
// Комбинирование параметров
router.navigate('/main', {
replace: true,
scrollRestoration: 'manual'
});Переход с установкой searchParams
router.navigate({
pathname: '/user/123',
// ?page=1&filters=[1,2]
searchParams: { page: 1, filters: [1, 2] }
});Передача search строки
Search можно передавать как ?, так и без:
// Переход с search параметрами (с ?)
router.navigate({
pathname: '/search',
search: '?query=test&page=1'
});
// Переход с search параметрами (без ?)
router.navigate({
pathname: '/search',
search: 'query=test&page=1'
});Особенности реализации
navigateне синхронный:
router.location.pathname; // /main
router.navigate('/user');
router.location.pathname; // /userАсинхронный переход navigateAsync
Асинхронный метод для перехода между страницами приложения, который позволяет дождаться завершения навигации.
Завершение навигации - успешный рендеринг нового маршрута.
// Переход на страницу и ожидание завершения
await router.navigateAsync('/main');
// Переход с заменой текущей записи в истории
await router.navigateAsync('/main', { replace: true });
// Переход с отключением автоматической прокрутки
await router.navigateAsync('/main', { scrollRestoration: 'manual' });
// Переход с передачей searchParams
await router.navigateAsync({
pathname: '/user/123',
searchParams: { page: 1, filters: [1, 2] }
});Возврат по истории назад (back)
Метод позволяющий вернутся на предыдущий URL в истории
router.navigate('/user'); // /main -> /user
router.back(); // возврат на /mainПерезагрузка страницы (reload)
Метод перезагружает текущую страницу
router.reload();state при навигации
О работе state можно ознакомиться здесь
Особенности реализации
- NextRouter: параметр
stateне поддерживается
Тип NavigationState
type NavigationState = {
/**
* Информация о предыдущем location, с которого был совершен переход
*/
referer: {
/**
* Путь предыдущего location
*/
pathname: string;
/**
* search предыдущего location
*/
search?: string;
};
} & Record<string, unknown>;Пример использования
// Навигация с передачей state и сохранением referer
router.routes.user.navigate({
params: { id: '123' },
state: { filter: 'value' },
});
router.location
// {
// pathname: '/user/123',
// search: '',
// state: { referer: { pathname: currentLocation.pathname, search: currentLocation.search }, filter: 'value' },
// }Работа с SearchParams
Роутер предоставляет несколько способов работы с search параметрами URL.
Типобезопасные изолированные searchParams
Базовая концепция
- Типобезопасность:
router.createSearchParamsStoreгарантирует, чтоvaluesсодержит именно те типы данных, которые были переданы в generic и заданы через валидацию. - Изолированность:
router.createSearchParamsStoreработает только с теми параметрами, которые были переданы в generic и заданы через валидацию, никак не влияя при этом на другие searchParams.
Пример использования
import { makeAutoObservable } from 'mobx';
import * as v from '@astral/validations';
import type {Router} from '#shared';
type SearchParams = {
id: string;
page?: number;
filters?: string[];
};
class UIStore {
private readonly searchStore;
public constructor(private readonly _router: Router) {
makeAutoObservable(this, {}, {autoBind: true});
this.searchStore = this._router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
page: v.optional(v.number()),
filters: v.optional(v.array(v.arrayItem(v.string()))),
},
});
}
public get userId() {
// Values === undefined, если валидация не прошла
return this.searchStore.values?.id;
}
public get currentPage() {
return this.searchStore.values?.page || 1;
}
public get activeFilters() {
return this.searchStore.values?.filters || [];
}
public setUserId(id: string) {
this.searchStore.set('id', id);
}
public setFilters = (filters: string[]) => {
this.searchStore.set('filters', filters);
}
public deleteFilters = () => {
this.searchStore.delete('filters');
}
}Values
store.values является observable.
Валидация
Валидация происходит:
- При инициализации
SearchParamsStore - При обращении к
valuesилиvalidationErrors - При изменении
searchParamsв рамках текущего store или в глобальномrouter.searchParams
store.values содержат:
undefinedесли валидация завершилась ошибкой- значения после парсинга если валидация завершилась без ошибок
validationSchema принимает схему объекта для @astral/validations.
Валидация при инициализации SearchParamsStore
Если валидация при инициализации SearchParamsStore завершилась ошибкой, то values будут содержать undefined, при этом router.searchParams не изменится если не заданы defaultValues.
Валидация при обращении к values
Валидация при обращении к values никак не меняет router.searchParams.
Валидация дат
Для валидации дат необходимо использовать метод transform
import * as v from '@astral/validations';
type SearchParams = {
date: string;
};
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
date: v.string(v.transform((value) => new Date(value))),
},
})
// values === undefined, если валидация не прошла
searchStore.values?.date;Обработка ошибок валидации
Если валидация завершилась ошибкой, то values будут содержать undefined, а validationErrors объект с информацией об ошибке:
import * as v from '@astral/validations';
type SearchParams = {
id: string;
};
// В url search отсутствует id
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
},
});
searchStore.values; // undefined
searchStore.validationErrors; // { id: { message: 'Не является строкой', code: 'astral-validations-string' } }
if (searchStore.validationErrors?.id.code === v.STRING_TYPE_ERROR_INFO.code) {
console.log('Передан не корректный id. Попробуйте...');
}Установка значений (set, setAll)
SearchParamsStore предоставляет несколько методов для изменения параметров.
По умолчанию все изменения не сохраняются в истории браузера (используется replace: true).
set
Устанавливает значение для конкретного параметра, сохраняя остальные параметры без изменений.
// Установка обязательного параметра
searchStore.set('id', '123');
// Установка массива
searchStore.set('filters', ['active', 'verified']);
// Сохранение в истории браузера
searchStore.set('page', 3, { replace: false });setAll
Устанавливает все параметры сразу, позволяя полностью перезаписать текущие значения или модифицировать их на основе предыдущих.
// Полная замена всех параметров
searchStore.setAll({ id: '456', page: 1 });
// Модификация на основе предыдущих значений
// prev может быть undefined
searchStore.setAll((prev) => ({
...prev,
page: (prev?.page || 0) + 1,
}));
// Сохранение в истории браузера
searchStore.setAll({ id: '789' }, { replace: false });Важно: setAll изменяет только те параметры, которые указаны в validationSchema.
router.setSearchParams({ id: '1', page: 1, test: 'test' });
searchStore.setAll({ id: '2', page: 2, isShowModal: true });
router.searchParams // { id: '2', page: 2, isShowModal: true }Удаление параметров (delete)
delete Удаляет конкретный параметр.
По-умолчанию удаление параметра не сохраняется в истории браузера (используется replace: true).
TypeScript не позволит удалить обязательные параметры - только опциональные.
type SearchParams = {
id: string; // Обязательный - нельзя удалить
page?: number; // Опциональный - можно удалить
filters?: string[]; // Опциональный - можно удалить
};
// ✅ Можно удалить опциональные параметры
searchStore.delete('page');
searchStore.delete('filters');
// ❌ TypeScript ошибка - нельзя удалить обязательный параметр
// searchStore.delete('id'); // Error: Argument of type '"id"' is not assignable to parameter of type '"page" | "filters"'
// Сохранение в истории браузера
searchStore.delete('page', { replace: false });Сброс значений (reset)
reset удаляет все параметры, которые указаны в generic и validationSchema.
По-умолчанию сброс не сохраняется в истории браузера (используется replace: true).
router.setSearchParams({ id: '1', page: 1, test: 'test' });
searchStore.reset();
router.searchParams // { test: 'test' }
// Сброс с сохранением в истории браузера
searchStore.reset({ replace: false });Частичный сброс значений
include и exclude позволяют сбросить только определенные параметры.
searchStore.setAll({ id: '1', page: 1 });
// Сброс только id
searchStore.reset({ include: ['id'] }); // { page: 1 }searchStore.setAll({ id: '1', page: 1 });
// Сброс всех параметров, кроме id
searchStore.reset({ exclude: ['id'] }); // { id: '1' }Создание нескольких searchParamsStore
В приложении можно создавать несколько searchParamsStore с разными validationSchema.
Так как searchParamsStore изолированы, то они не будут влиять друг на друга.
const tableFiltersStore = router.createSearchParamsStore<{
page: number;
filters: string[];
}>({
validationSchema: {
page: v.optional(v.number()),
filters: v.optional(v.array(v.arrayItem(v.string()))),
},
});
// Если в searchParams есть modalOrgId, то открывается модалка
const detailsModalSearchParamsStore = router.createSearchParamsStore<{
modalOrgId: string;
}>({
validationSchema: {
modalOrgId: v.optional(v.string(v.guid())),
},
});
// Никак не повлияет на modalOrgId
tableFiltersStore.setAll({ page: 1, filters: ['active', 'verified'] });
detailsModalSearchParamsStore.set('modalOrgId', '123');
// Никак не повлияет на modalOrgId
tableFiltersStore.reset();defaultValues
defaultValues применяются только при инициализации SearchParamsStore или сбросе значений.
Подробнее ниже:
При инициализации SearchParamsStore searchParams пустые
router.setSearchParams({});
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.optional(v.string()),
},
defaultValues: {
id: 'default-user',
},
});
searchStore.values; // { id: 'default-user' }
router.searchParams; // { id: 'default-user' }При инициализации SearchParamsStore searchParams частично пустые
router.setSearchParams({ page: 2 });
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
page: v.optional(v.number()),
},
defaultValues: {
id: 'default-user',
page: 1,
},
});
// Сохранился исходный page, а для id установлено defaultValues
searchStore.values; // { id: 'default-user', page: 2 }
router.searchParams; // { id: 'default-user', page: 2 }При инициализации SearchParamsStore валидация не прошла
router.setSearchParams({ id: 1 });
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
},
defaultValues: {
id: 'default-user',
},
});
searchStore.values; // { id: 'default-user' }
router.searchParams; // { id: 'default-user' }После вызова reset()
router.setSearchParams({ id: '1' });
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
},
defaultValues: {
id: 'default-user',
},
});
searchStore.reset();
router.searchParams // { test: 'test' }При инициализации SearchParamsStore валидация прошла, но не все optional параметры находились в исходном searchParams
router.setSearchParams({ id: '1' });
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
page: v.optional(v.number()),
},
defaultValues: {
id: 'default-user',
page: 1,
},
});
searchStore.values; // { id: '1', page: 1 }
router.searchParams // { id: '1', page: 1 }initialValues
initialValues - делают hard set значений в values и глобальные searchParams.
type SearchParams = {
id: string;
};
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
},
initialValues: {
id: '1',
},
});
searchStore.values; // { id: '1' }
router.searchParams; // { id: '1' }Аналогичный пример:
type SearchParams = {
id: string;
};
const searchStore = router.createSearchParamsStore<SearchParams>({
validationSchema: {
id: v.string(),
},
});
searchStore.setAll({ id: '1' });
searchStore.values; // { id: '1' }
router.searchParams; // { id: '1' }Особенности работы с nextjs
SearchParams недоступны на сервере без getServerSideProps
При обращении к router.searchParams на сервере nextjs searchParams будут {}.
Для того чтобы значение на сервере было актуальным, необходимо для конкретной страницы определить getServerSideProps:
export default () => {...}
// Достаточно просто определить функцию
export async function getServerSideProps() {
return {
props: {},
};
}При частичной интеграции mobx-router в nextRouter.query строки записываются с кавычками
Если вы одновременно используете nextRouter.query и mobxRouter.setSearchParams, то при вызове mobxRouter.setSearchParams в nextRouter.query будут находится строки в кавычках:
{
id: '"123"'
}Для решения данной проблемы необходимо реализовывать кастомный парсер searchParams. Инструкции
Не типизированные searchParams, приведенные к js структурам
Рекомендуется использовать типобезопасные searchParams из router.createSearchParamsStore.
router.searchParams содержит не типизированные searchParams, приведенные к js структурам.
router.searchParams в качестве значений может содержать:
type ParsedSearchParamsValue =
| string
| boolean
| number
| null
| undefined
| ParsedSearchParamsValue[]
| Record<string, ParsedSearchParamsValue>;router.searchParams является observable.
Пример использования:
import { makeAutoObservable } from 'mobx';
class Store {
constructor(private readonly _router: Router) {
makeAutoObservable(this);
}
get currentPage() {
return this._router.searchParams.page || 1;
}
}Установка searchParams
Метод setSearchParams позволяет изменять search параметры с сохранением текущего пути:
router.setSearchParams({ page: 1, type: 'request' });
// Изменение с доступом к предыдущим параметрам
type PrevParams = { page: number };
router.setSearchParams((prev) => ({ page: ++(prev as PrevParams).page }));
// Установка с заменой записи в истории (по умолчанию replace: true)
router.setSearchParams({ page: 1 }, { replace: false });Сброс searchParams
Для сброса достаточно пробросить пустой объект:
router.setSearchParams({});Парсинг и сериализация searchParams
Парсинг и сериализация параметров по-дефолту осуществляется через JSON.parse и JSON.stringify.
Сериализация
- Строки сериализуются с кавычками:
{ param: 'test' }→?param="test" - Числа и булевы значения преобразуются напрямую:
{ num: 123, bool: true }→?num=123&bool=true - Массивы и объекты сериализуются в JSON:
{ arr: [1,2,3] }→?arr=[1,2,3] - Значения
undefined,null,NaN, пустые строки, пустые массивы, пустые объекты пропускаются и не добавляются в URL
Парсинг
- Строки в кавычках парсятся как строки:
?param="test"→{ param: 'test' } - Числа и булевы значения автоматически приводятся к соответствующим типам:
?num=123→{ num: 123 } - Массивы парсятся как json:
?arr=[1,2,3]→{ arr: [1,2,3] } - Объекты парсятся как json:
?obj={"a":2}→{ obj: { a: 2 } } - Строка
"undefined"преобразуется вundefined - Строка
"null"преобразуется вnull - При ошибке парсинга значение остается строкой:
?arr=[1,2-3]→{ arr: '[1,2,3]' },?param=str→{ param: 'str' } - Повторяющиеся параметры собираются в массив:
?arr=1&arr=2→{ arr: [1,2] }
Кастомный парсинг и сериализация searchParams
Может потребоваться изменить поведение дефолтного парсера.
Для этого необходимо реализовать class имплементирующий интерфейс ISearchParamsParser:
type CustomParsedSearchParams = Record<string, string>;
// import { ISearchParamsParser } from '@astral/mobx-router';
interface ISearchParamsParser {
parse(rawSearchParams: URLSearchParams): CustomParsedSearchParams;
serialize(searchParams: CustomParsedSearchParams): URLSearchParams;
}И передать его при создании router:
new Router(
provider,
routesConfig,
{ searchParamsParser: customParser }
);После этого searchParams и setSearchParams будут работать с типом CustomParsedSearchParams.
router.searchParams; // Record<string, string>
router.setSearchParams; // (searchParams: Record<string, string>) => void SearchParamsStore также начнет использовать тип, заданный в парсере, и не позволит передать в generic неправильный тип:
router.createSearchParamsStore<{ id: number }>(); // Будет ошибка потому что тип должен соответствовать Record<string, string>Сохранение Search параметров при навигации
Роутер предоставляет возможность сохранять существующие search параметры при переходах между маршрутами. Функция полезна в сценариях, где нужно сохранить состояние фильтров, сортировок или других параметров при навигации между разными страницами приложения.
Для сохранения параметров, необходимо передать параметр preserveOnNavigate=true и установить значения. После редиректа, все параметры, входящие в validationSchema и установленные в URL, будут сохранены
type SearchParams = {
id: string;
redirectUrl: string;
};
const searchStore = router.createSearchParamsStore<SearchParams>({
preserveOnNavigate: true,
validationSchema: {
id: v.string(),
redirectUrl: v.string(),
},
});
store.setAll({
id: '1',
redirectUrl: '2',
});
router.navigate('/');
router.searchParams; // { id: '1', redirectUrl: '2' }Если нужно сохранять только определенные параметры - необходимо передать их ключи массивом в preserveOnNavigate:
type SearchParams = {
id: string;
redirectUrl: string;
};
const searchStore = router.createSearchParamsStore<SearchParams>({
preserveOnNavigate: ['redirectUrl'],
validationSchema: {
id: v.string(),
redirectUrl: v.string(),
},
});
store.setAll({
id: '1',
redirectUrl: '2',
});
router.navigate('/');
router.searchParams; // { redirectUrl: '2' }Сброс сохраненного параметра
После сброса сохраненного параметра, он более не будет сохраняться при переходах.
Сохранение при перезагрузке страницы
Чтобы при перезагрузке страницы preserveOnNavigate параметры продолжали сохраняться при навигации, SearchParamsStore, инициализирующий их сохранение должен создаваться глобально.
Особенности реализации
Механизм сохранения search параметров основан на blocker, необходимо учитывать особенности его реализации
Debug
Если возникают проблемы установки значений - включите debug режим.
Особенности реализации
SearchParams устанавливаются синхронно
Методы setSearchParams, setAll, set устанавливают значения синхронно.
router.searchParams; // {}
router.setSearchParams({ test: 1 });
router.searchParams; // { test: 1 }В отличии от navigate:
router.location.pathname; // /main
router.navigate('/user');
router.location.pathname; // /userLocation
Свойство location
Роутер предоставляет реактивное свойство location, которое автоматически обновляется при изменении URL в браузере.
type Location = {
pathname: string; // Текущий путь (например, '/dashboard')
search?: string; // Query параметры (например, '?id=123')
hash?: string; // Hash фрагмент (например, '#section')
};Типобезопасная навигация
Для типобезопасной навигации используйте предопределенные routes.
См. раздел Использование routes для подробной информации о навигации по routes.
Блокировка маршрутов (blocker)
Базовая концепция
router.createBlocker(shouldBlock) создаёт блокер. Не забудьте его активировать.
shouldBlock(nextLocation) — функция, которая возвращает true, если переход нужно остановить и false если его нужно продолжить.
Когда переход заблокирован, blocker.isBlocked === true.
Для управления блокировкой используются методы: activate, proceed, reset, destroy
Пример использования
import { makeAutoObservable } from 'mobx';
import type { Router } from '#shared';
class UIStore {
private readonly blocker;
public isDirty: boolean = false;
public showModal: boolean = false;
public constructor(private readonly _router: Router) {
makeAutoObservable(this, {}, {autoBind: true});
/**
* Создаём блокер, который будет перехватывать навигацию.
* Функция обратного вызова вызывается перед каждым переходом.
* Если возвращается true — переход блокируется.
*/
this.blocker = this._router.createBlocker((nextLocation) => {
const isBlocked = this.isDirty;
if(isBlocked) {
this.openModal();
}
return isBlocked;
});
}
public mount = () => {
this.blocker.activate();
};
public unmount = () => {
this.blocker.destroy();
};
private openModal = () => {
this.showModal = true;
};
public confirm = () => {
// Продолжаем переход на заблокированный маршрут
this.blocker.proceed();
};
public cancel = () => {
// Снимаем блокировку и остаемся на текущей странице
this.blocker.reset();
};
}
const Component = observer(() => {
const [store] = useState(createUIStore);
useEffect(() => {
store.mount();
return () => {
store.unmount();
};
}, []);
});Особенности реализации
React Router v6 и v7: Блокеры, созданные через createBlocker, не будут работать одновременно с хуком useBlocker.
Если вы используете useBlocker, необходимо полностью полагаться на него для управления блокировкой переходов, или использовать подход через метод createBlocker
Next.js: Для блокировки навигации используется выброс исключения throw new Error при попытке перехода на заблокированный маршрут.
Это позволяет остановить переход до того, как NextRouter выполнит смену URL.
Активация блокера
Каждый блокер должен быть активирован перед началом работы.
blocker.activate();Использование в компоненте
При использовании блокера в компоненте, его необходимо активировать и деактивировать в useEffect:
useEffect(() => {
blocker.activate();
return () => {
blocker.destroy();
};
}, []);Если этого не сделать, то в Strict Mode будут проблемы в работе блокера
Глобальный store
При использовании блокера вне компонента его можно активировать сразу в конструкторе класса:
class GlobalStore {
constructor(private readonly _router: Router) {
this.blocker = this._router.createBlocker((nextLocation) => {
return nextLocation.pathname === '/main';
});
this.blocker.activate();
}
}Состояние блокировки
blocker.isBlocked является observable
const blocker = router.createBlocker((nextLocation) => {
return nextLocation.pathname === '/main'
})
router.navigate('/main')
blocker.isBlocked // trueУправления блокировкой
// Активировать блокировку
blocker.activate()
// Продолжить переход на заблокированный маршрут.
blocker.proceed()
// Отменить переход и остаться на текущей странице.
blocker.reset()
// Удалить блокер
blocker.destroy()Работа с несколькими блокерами
Можно создавать несколько блокеров одновременно. Навигация будет заблокирована, если хотя бы один из активных блокеров запретит переход на другую страницу.
Каждый блокер имеет свое независимое состояние isBlocked
const blockMain = router.createBlocker((next) => next.pathname === '/main');
blockMain.activate();
const blockUsers = router.createBlocker((next) => next.pathname === '/users');
blockUsers.activate();
router.navigate('/users'); // переход заблокирован
router.navigate('/settings'); // переход разрешёнУправление жизненным циклом (destroy)
Каждый блокер подписывается на события изменения маршрута. Необходимо удалять его, если он больше не используется.
Если не вызывать destroy, блокер продолжит слушать изменения маршрутов, что может привести к непредсказуемому поведению в навигации.
const blocker = router.createBlocker((nextLocation) => nextLocation.pathname === '/main');
blocker.activate();
blocker.destroy(); // снимает все подписки и очищает внутренние ссылкиMatchPath - Сопоставление маршрутов
Функция matchPath позволяет получить детальную информацию о текущем маршруте и извлечь параметры из URL. Это мощный инструмент для анализа URL и получения метаданных о совпадении паттерна маршрута с текущим путем.
API
Синтаксис
router.matchPath(pattern: MatchPathPattern | string, path: string): MatchPathInfo | nullПараметры
pattern- Паттерн маршрута (строка или объектMatchPathPattern)- При передаче строки используется точное сопоставление (
isExact: trueпо умолчанию) - При передаче объекта
MatchPathPatternможно указатьisExact: falseдля частичного сопоставления
- При передаче строки используется точное сопоставление (
path- URL pathname для сопоставления
Возвращаемое значение
type MatchPathInfo = {
params: Record<string, string>; // Динамические параметры из URL
pathname: string; // Полный совпадающий путь
pathnameBase: string; // Базовый путь для дочерних маршрутов
pattern: string; // Исходный паттерн маршрута
};Базовый пример
Самый простой способ использования - сопоставление строкового паттерна с URL:
// Сопоставление простого маршрута
const match = router.matchPath('/user/:id', '/user/123');
// {
// params: { id: '123' },
// pathname: '/user/123',
// pattern: '/user/:id',
// pathnameBase: '/user/123'
// }
// Сопоставление с несколькими параметрами
const postMatch = router.matchPath('/posts/:category/:id', '/posts/tech/456');
// {
// params: { category: 'tech', id: '456' },
// pathname: '/posts/tech/456',
// pattern: '/posts/:category/:id',
// pathnameBase: '/posts/tech/456'
// }
// Когда сопоставления нет - возвращается null
const noMatch = router.matchPath('/user/:id', '/posts/123');
// nullПолное сопоставление isExact
По умолчанию isExact равен true, что означает точное сопоставление пути.
Для более точного контроля над сопоставлением используйте объект MatchPathPattern:
// Точное сопоставление (значение по умолчанию)
const exactMatch = router.matchPath(
{ path: '/user/:id' },
'/user/123'
);
// {
// params: { id: '123' },
// pathname: '/user/123',
// pattern: '/user/:id',
// pathnameBase: '/user/123'
// }
// Частичное сопоставление (isExact: false)
const partialMatch = router.matchPath(
{ path: '/user', isExact: false },
'/user/123/settings'
);
// {
// params: {},
// pathname: '/user/123/settings',
// pattern: '/user',
// pathnameBase: '/user'
// }Сопоставление с wildcard
// Сопоставит любые пути, начинающиеся с '/posts/'
const wildcardMatch = router.matchPath(
{ path: '/posts/*' },
'/posts/tech/123/comments'
);
// {
// params: {},
// pathname: '/posts/tech/123/comments',
// pattern: '/posts/*',
// pathnameBase: '/posts'
// }Location
router.location - это MobX observable.
API
type Location = {
pathname: string; // Текущий путь (например, '/dashboard')
search?: string; // Query параметры (например, '?id=123&tab=settings')
hash?: string; // Hash фрагмент (например, '#section')
state?: NavigationState;
};pathname
Основное свойство pathname содержит текущий путь URL без query параметров и hash фрагмента.
Примеры использования
// Получение текущего пути
const currentPath = router.location.pathname;
console.log(currentPath); // '/user/123'Реактивное использование с MobX
import { makeAutoObservable } from 'mobx';
class NavigationStore {
constructor(private readonly _router: Router) {
makeAutoObservable(this);
}
// Computed свойства на основе pathname
get isAdminPage() {
return this._router.location.pathname.startsWith('/admin');
}
}Подписка на события маршрутизации
Методы subscribeRouteChangeStart и subscribeRouteChangeEnd позволяют подписываться на начало и окончание навигации.
Особенности реализации
- React-Router v6 и v7: На данный момент не поддерживается метод
subscribeRouteChangeStart - Начало навигации (subscribeRouteChangeStart) — вызывается сразу после инициирования перехода на новый маршрут, до фактической активации/рендера компонентов.
- Окончание навигации (subscribeRouteChangeEnd) — вызывается после успешного завершения перехода и рендера нового маршрута.
API
Синтаксис
const disposeStart = router.subscribeRouteChangeStart(handler: (location: Location) => void): () => void;
const disposeEnd = router.subscribeRouteChangeEnd(handler: (location: Location) => void): () => void;Параметры
handler- функция-обработчик, которая будет вызвана с объектом Location или строкойurl:- для
subscribeRouteChangeStart— при начале навигации - для
subscribeRouteChangeEnd— после завершения навигации
- для
Возвращаемое значение
Функция subscribeRouteChangeStart и subscribeRouteChangeEnd возвращает функцию dispose, которая отписывает обработчик от событий.
Примеры использования
// Подписка на начало перехода:
const disposeStart = router.subscribeRouteChangeStart((location) => {
console.log('Начинается переход на:', location.pathname);
});
// Отписка от события перехода:
disposeStart();
// Подписка на окончание перехода:
const disposeEnd = router.subscribeRouteChangeEnd((location) => {
console.log('Переход завершён:', location.pathname);
});
// Отписка от события перехода:
disposeEnd();Debug
В debug режиме router будет логировать внутренние операции.
const router = new MobxRouter(
provider,
routesConfig,
{ logLevel: 'debug' },
);Тестирование
Для мока роутера используйте createRouterMockFactory.
RouterMock полностью эмулирует поведение реального роутера в памяти, сохраняя реактивность и особенности поведения (например, асинхронный navigate).
RouterMock создается на основе реального роутера, что позволяет переиспользовать его конфигурацию и маршруты.
Создание routerMock
shared/services/Router
import { MobxRouter } from '@astral/mobx-router';
import { ReactRouterProvider } from '@astral/mobx-router-react';
export const reactRouterProvider = new ReactRouterProvider();
const routesConfig = {
home: MobxRouter.defineRoute({
pattern: '/home',
}),
user: MobxRouter.defineRoute({
pattern: '/user/:id',
generatePath: (params: { id: string }) => `/user/${params.id}`,
}),
profile: MobxRouter.defineRoute({
pattern: '/profile/:userId?',
generatePath: ({ userId }: { userId?: string }) => userId ? `/profile/${userId}` : '/profile' ,
}),
};
export const router = new MobxRouter(reactRouterProvider, routesConfig);shared/_test/RouterMock
import { createRouterMockFactory } from '@astral/mobx-router';
import { router } from '#shared/services/Router';
export const createRouterMock = createRouterMockFactory(router);API
type Params = {
initialLocation?: Location;
initialSearchParams?: SearchParams;
};Вспомогательные методы
waitNavigation
Ожидает завершения навигации.
it('После успешного submit происходит переход на страницу с организацией', async () => {
const routerMock = createRouterMock();
const sut = createStore(routerMock);
await sut.submit();
// Navigate - асинхронная операция. Нужно дождаться её завершения
await routerMock.waitNavigation();
expect(sut.location.pathname).toBe(routerMock.routes.org.generateHref());
});Использование routerMock
Базовый пример
it('Удаляется организация, если активен home route', () => {
const routerMock = createRouterMock({ initialLocation: { pathname: '/home' } });
const sut = createStore(routerMock);
expect(sut.orgId).toBeUndefined();
});Работа с навигацией
it('После успешного submit происходит переход на страницу с организацией', async () => {
const routerMock = createRouterMock();
const sut = createStore(routerMock);
await sut.submit();
// Navigate - асинхронная операция. Нужно дождаться её завершения
await routerMock.waitNavigation();
expect(sut.location.pathname).toBe(routerMock.routes.org.generateHref());
});Работа с searchParams
it('Filters содержит начальные значения из url', async () => {
const routerMock = createRouterMock({
initialSearchParams: {
page: 1,
limit: 10,
},
});
const sut = createStore(routerMock);
expect(sut.filters).toEqual({
page: 1,
limit: 10,
});
});it('Фильтры удаляются из url после submit', async () => {
const routerMock = createRouterMock({
initialSearchParams: {
page: 1,
limit: 10,
},
});
const sut = createStore(routerMock);
await sut.submit();
expect(routerMock.searchParams).toEqual({});
});Работа с routeParams
...
const routesConfig = {
user: MobxRouter.defineRoute({
pattern: '/org/:id',
generatePath: (params: { id: string }) => `/org/${params.id}`,
}),
};
...it('orgId содержит значение из url', () => {
const routerMock = createRouterMock({ initialLocation: { pathname: '/org/123' } });
const sut = createStore(routerMock);
expect(sut.orgId).toBe('123');
});