diff --git a/docs/docs/misc/wait.mdx b/docs/docs/misc/wait.mdx
index 7bddb3d..9124cd3 100644
--- a/docs/docs/misc/wait.mdx
+++ b/docs/docs/misc/wait.mdx
@@ -23,3 +23,23 @@ wait().then(() => {
console.log('Timeout passed');
});
```
+
+### Aborting
+
+You can pass _abort signal_ from [AbortController](https://developer.mozilla.org/ru/docs/Web/API/AbortController) to reject promise:
+
+```js
+import { wait } from '@krutoo/utils';
+
+const controller = new AbortController();
+
+wait(1000, { signal: controller.signal })
+ .then(() => {
+ // ...
+ })
+ .catch(reason => {
+ console.log(reason); // "Fake reason"
+ });
+
+controller.abort('Fake reason');
+```
diff --git a/docs/docs/react/use-location.mdx b/docs/docs/react/use-location.mdx
new file mode 100644
index 0000000..ee0cfc4
--- /dev/null
+++ b/docs/docs/react/use-location.mdx
@@ -0,0 +1,41 @@
+export const meta = {
+ category: 'React',
+ title: 'useLocation',
+};
+
+# `useLocation`
+
+React hook of state of current location provided by router.
+
+### Usage
+
+```tsx
+import { useLocation } from '@krutoo/utils/react';
+
+function ProfilePage() {
+ const { pathname, hash, search } = useLocation();
+
+ return <>{/* ... */}>;
+}
+```
+
+### Requirements
+
+You need to wrap your root component to special `RouterContext` to make router specific hooks working:
+
+```tsx
+import { createRoot } from 'react-dom';
+import { BrowserRouter } from '@krutoo/utils/router';
+import { RouterContext } from '@krutoo/utils/react';
+import { App } from '#components/app';
+
+const router = new BrowserRouter();
+
+router.connect();
+
+createRoot(document.querySelector('#root')).render(
+
+
+ ,
+);
+```
diff --git a/docs/docs/react/use-navigate.mdx b/docs/docs/react/use-navigate.mdx
new file mode 100644
index 0000000..2deff76
--- /dev/null
+++ b/docs/docs/react/use-navigate.mdx
@@ -0,0 +1,57 @@
+export const meta = {
+ category: 'React',
+ title: 'useNavigate',
+};
+
+# `useNavigate`
+
+React hook for navigate between routes of application.
+
+### Usage
+
+```tsx
+import { useNavigate } from '@krutoo/utils/react';
+
+function App() {
+ const navigate = useNavigate();
+
+ const handleAvatarClick = () => {
+ // navigating to specific route
+ navigate('/profile');
+ };
+
+ const handleBackClick = () => {
+ // navigating on history
+ navigate.go(-1);
+ };
+
+ return (
+
+
+
+ {/* ... */}
+
+ );
+}
+```
+
+### Requirements
+
+You need to wrap your root component to special `RouterContext` to make router specific hooks working:
+
+```tsx
+import { createRoot } from 'react-dom';
+import { BrowserRouter } from '@krutoo/utils/router';
+import { RouterContext } from '@krutoo/utils/react';
+import { App } from '#components/app';
+
+const router = new BrowserRouter();
+
+router.connect();
+
+createRoot(document.querySelector('#root')).render(
+
+
+ ,
+);
+```
diff --git a/docs/docs/react/use-route-params.mdx b/docs/docs/react/use-route-params.mdx
new file mode 100644
index 0000000..f3d291c
--- /dev/null
+++ b/docs/docs/react/use-route-params.mdx
@@ -0,0 +1,41 @@
+export const meta = {
+ category: 'React',
+ title: 'useRouteParams',
+};
+
+# `useRouteParams`
+
+React hook to exec _pathname pattern_ and read params from current location.
+
+### Usage
+
+```tsx
+import { useRouteParams } from '@krutoo/utils/react';
+
+function App() {
+ const { userId } = useRouteParams('/users/:userId');
+
+ return <>{/* ... */}>;
+}
+```
+
+### Requirements
+
+You need to wrap your root component to special `RouterContext` to make router specific hooks working:
+
+```tsx
+import { createRoot } from 'react-dom';
+import { BrowserRouter } from '@krutoo/utils/router';
+import { RouterContext } from '@krutoo/utils/react';
+import { App } from '#components/app';
+
+const router = new BrowserRouter();
+
+router.connect();
+
+createRoot(document.querySelector('#root')).render(
+
+
+ ,
+);
+```
diff --git a/docs/docs/router/browser-router.mdx b/docs/docs/router/browser-router.mdx
new file mode 100644
index 0000000..3a82829
--- /dev/null
+++ b/docs/docs/router/browser-router.mdx
@@ -0,0 +1,158 @@
+import { Callout } from '#components/callout/callout.tsx';
+
+export const meta = {
+ category: 'Router',
+ title: 'BrowserRouter',
+};
+
+# Router
+
+Package provides `BrowserRouter` - basic implementation for controlling _client routing_ in browser.
+
+
+ SSR ready
+
+ Can be used in Node.js for Server Side Rendering or Static Site Generation, see next articles.
+
+
+
+### Basic usage
+
+```tsx
+import { BrowserRouter } from '@krutoo/utils/router';
+
+const router = new BrowserRouter();
+
+// connect router to Web APIs
+router.connect();
+
+// now we can use it to get location info...
+console.log(router.getLocation().pathname);
+
+// ...and for redirects
+router.navigate('/profile/settings');
+
+// external links also supported
+router.navigate('https://google.com');
+
+// you can navigate by history (back for example)
+router.go(-1);
+```
+
+### React bindings
+
+Package provides some hooks and context for working with router in components.
+
+You need to wrap your root component to special `RouterContext` to make router specific hooks working:
+
+```tsx
+import { createRoot } from 'react-dom';
+import { BrowserRouter } from '@krutoo/utils/router';
+import { RouterContext } from '@krutoo/utils/react';
+import { App } from '#components/app';
+
+// we use provided implementation here but you can use your own
+const router = new BrowserRouter();
+
+// to make it works you need to call connect
+router.connect();
+
+createRoot(document.querySelector('#root')).render(
+
+
+ ,
+);
+```
+
+Now you can implement simple routing for example like this:
+
+```tsx
+const ROUTES = [
+ {
+ path: '/',
+ render: () => ,
+ },
+ {
+ path: '/profile',
+ render: () => ,
+ },
+ {
+ path: '/items/:itemId',
+ render: () => ,
+ },
+];
+
+function App() {
+ const { pathname } = useLocation();
+
+ // find current route
+ const currentRoute = useMemo(() => {
+ for (const route of ROUTES) {
+ const pattern = new URLPattern({ pathname: route.path });
+
+ if (pattern.test({ pathname })) {
+ return route;
+ }
+ }
+ }, [pathname]);
+
+ // render current route
+ <>{currentRoute?.render()}>;
+}
+```
+
+And of course you can use other hooks in any of your components:
+
+```tsx
+function ItemPage() {
+ // current location info
+ const { pathname, hash, search } = useLocation();
+
+ // route params
+ const { groupId, itemId } = useRouteParams('/items/:groupId/:itemId');
+
+ // navigate function
+ const navigate = useNavigate();
+
+ return <>{/* ... */}>;
+}
+```
+
+### Using for SSR or SSG
+
+`BrowserRouter` can be used in Node.js (or other server environment) to implement Server Side Rendering or Static Site Generation.
+
+You just don't call `connect()` method because under the hood it accesses browser APIs.
+
+Next example shows how you can implement SSR with React:
+
+```tsx
+import express from 'express';
+import { renderToString } from 'react-dom';
+import { BrowserRouter } from '@krutoo/utils/router';
+import { RouterContext } from '@krutoo/utils/react';
+import { ROUTES } from '#app/routes';
+import { App } from '#components/app';
+
+const app = express();
+
+for (const route of ROUTES) {
+ app.get(ROUTES.path, (req, res) => {
+ const router = new BrowserRouter({
+ defaultLocation: { pathname: req.path },
+ });
+
+ const markup = renderToString(
+
+
+ ,
+ );
+
+ res.send(markup);
+ });
+}
+
+app.listen(8080, () => {
+ console.log(`Server running at http://localhost:${8080}`);
+});
+```
diff --git a/package.json b/package.json
index 5e45f8a..cbf6d85 100644
--- a/package.json
+++ b/package.json
@@ -115,6 +115,7 @@
"./math": "./dist/math/mod.js",
"./misc": "./dist/misc/mod.js",
"./react": "./dist/react/mod.js",
+ "./router": "./dist/router/mod.js",
"./rspack": "./dist/rspack/mod.js",
"./store": "./dist/store/mod.js",
"./testing": "./dist/testing/mod.js",
diff --git a/src/react/context/router-context.ts b/src/react/context/router-context.ts
new file mode 100644
index 0000000..b3cea43
--- /dev/null
+++ b/src/react/context/router-context.ts
@@ -0,0 +1,13 @@
+import { createContext, type Context } from 'react';
+import { getStubLocation } from '../../router/utils.ts';
+import type { Router } from '../../router/types.ts';
+
+export const RouterContext: Context = createContext({
+ getLocation: getStubLocation,
+ navigate: () => {},
+ go: () => {},
+ subscribe: () => () => {},
+ connect: () => () => {},
+});
+
+RouterContext.displayName = 'RouterContext';
diff --git a/src/react/mod.ts b/src/react/mod.ts
index a73081b..2097f97 100644
--- a/src/react/mod.ts
+++ b/src/react/mod.ts
@@ -57,3 +57,9 @@ export * from './portal.tsx';
// IOC
export { ContainerContext } from './context/container-context.ts';
export { useDependency } from './use-dependency.ts';
+
+// router
+export { RouterContext } from './context/router-context.ts';
+export { type UseNavigateReturn, useNavigate } from './router/use-navigate.ts';
+export { useLocation } from './router/use-location.ts';
+export { useRouteParams } from './router/use-route-params.ts';
diff --git a/src/react/router/use-location.ts b/src/react/router/use-location.ts
new file mode 100644
index 0000000..eb878df
--- /dev/null
+++ b/src/react/router/use-location.ts
@@ -0,0 +1,25 @@
+import { useContext, useState } from 'react';
+import { RouterContext } from '../context/router-context.ts';
+import type { RouterLocation } from '../../router/types.ts';
+import { useIsomorphicLayoutEffect } from '../use-isomorphic-layout-effect.ts';
+
+/**
+ * Returns current router location state.
+ * @returns Location.
+ */
+export function useLocation(): RouterLocation {
+ const router = useContext(RouterContext);
+ const [location, setLocation] = useState(() => router.getLocation());
+
+ useIsomorphicLayoutEffect(() => {
+ const sync = () => {
+ setLocation(router.getLocation());
+ };
+
+ sync();
+
+ return router.subscribe(sync);
+ }, [router]);
+
+ return location;
+}
diff --git a/src/react/router/use-navigate.ts b/src/react/router/use-navigate.ts
new file mode 100644
index 0000000..140665f
--- /dev/null
+++ b/src/react/router/use-navigate.ts
@@ -0,0 +1,24 @@
+import { useContext, useMemo } from 'react';
+import type { Router } from '../../router/types.ts';
+import { RouterContext } from '../context/router-context.ts';
+
+export type UseNavigateReturn = Router['navigate'] & {
+ go: Router['go'];
+};
+
+/**
+ * Returns navigate function with extra methods.
+ * @returns Navigate function.
+ */
+export function useNavigate(): UseNavigateReturn {
+ const router = useContext(RouterContext);
+
+ return useMemo(() => {
+ const navigateWrapper: UseNavigateReturn = (url: string) => router.navigate(url);
+
+ // eslint-disable-next-line react-hooks/immutability
+ navigateWrapper.go = router.go;
+
+ return navigateWrapper;
+ }, [router]);
+}
diff --git a/src/react/router/use-route-params.ts b/src/react/router/use-route-params.ts
new file mode 100644
index 0000000..436bfab
--- /dev/null
+++ b/src/react/router/use-route-params.ts
@@ -0,0 +1,23 @@
+import { useMemo } from 'react';
+import { useLocation } from './use-location.ts';
+
+/**
+ * Returns pathname pattern exec result on current location.
+ * @param pathnamePattern Pathname pattern.
+ * @returns Params.
+ *
+ * @example
+ * ```js
+ * const { userId } = useRouteParams('/user/:userId');
+ * ```
+ */
+export function useRouteParams(pathnamePattern: string): Record {
+ const location = useLocation();
+
+ return useMemo(() => {
+ // @todo брать реализацию URLPattern из какого-нибудь контекста?
+ const pattern = new URLPattern({ pathname: pathnamePattern });
+
+ return pattern.exec({ pathname: location.pathname })?.pathname.groups ?? {};
+ }, [location, pathnamePattern]);
+}
diff --git a/src/router/browser-router.ts b/src/router/browser-router.ts
new file mode 100644
index 0000000..d756730
--- /dev/null
+++ b/src/router/browser-router.ts
@@ -0,0 +1,81 @@
+import type { Router, RouterLocation } from './types.ts';
+import { getStubLocation, normalizeLocation } from './utils.ts';
+
+export interface BrowserRouterConfig {
+ /** Useful when you use BrowserRouter on server. */
+ defaultLocation?: RouterLocation;
+}
+
+/**
+ * Router implementation for using in browser environment.
+ * Can be used in Node.js (or other server environment) while `.connect()` is not called.
+ */
+export class BrowserRouter implements Router {
+ private location: RouterLocation;
+ private listeners: Set;
+
+ constructor({ defaultLocation }: BrowserRouterConfig = {}) {
+ this.location = defaultLocation ?? getStubLocation();
+ this.listeners = new Set();
+ }
+
+ private setLocation(location: RouterLocation): void {
+ this.location = normalizeLocation(location);
+
+ for (const listener of this.listeners) {
+ listener();
+ }
+ }
+
+ getLocation(): RouterLocation {
+ return this.location;
+ }
+
+ navigate(url: string): void {
+ const currentUrl = new URL(window.location.href);
+ const nextUrl = new URL(url, window.location.href);
+
+ if (nextUrl.origin !== currentUrl.origin) {
+ window.location.href = nextUrl.href;
+ return;
+ }
+
+ const location = normalizeLocation(nextUrl);
+
+ window.history.pushState(null, '', `${location.pathname}${location.search}${location.hash}`);
+ window.scrollTo(0, 0);
+ this.setLocation(location);
+ }
+
+ go(delta: number): void {
+ window.history.go(delta);
+ }
+
+ connect(): () => void {
+ const sync = () => {
+ const url = new URL(window.location.href);
+
+ this.setLocation({
+ pathname: url.pathname,
+ search: url.search,
+ hash: url.hash,
+ });
+ };
+
+ sync();
+
+ window.addEventListener('popstate', sync);
+
+ return () => {
+ window.removeEventListener('popstate', sync);
+ };
+ }
+
+ subscribe(listener: VoidFunction): () => void {
+ this.listeners.add(listener);
+
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+}
diff --git a/src/router/mod.ts b/src/router/mod.ts
new file mode 100644
index 0000000..84bb4e8
--- /dev/null
+++ b/src/router/mod.ts
@@ -0,0 +1,2 @@
+export type { Router, RouterLocation } from './types.ts';
+export { type BrowserRouterConfig, BrowserRouter } from './browser-router.ts';
diff --git a/src/router/types.ts b/src/router/types.ts
new file mode 100644
index 0000000..4857e7a
--- /dev/null
+++ b/src/router/types.ts
@@ -0,0 +1,13 @@
+export interface RouterLocation {
+ pathname: string;
+ hash: string;
+ search: string;
+}
+
+export interface Router {
+ getLocation(): RouterLocation;
+ navigate(url: string): void;
+ go(delta: number): void;
+ connect(): () => void;
+ subscribe(listener: VoidFunction): () => void;
+}
diff --git a/src/router/utils.ts b/src/router/utils.ts
new file mode 100644
index 0000000..eeaf351
--- /dev/null
+++ b/src/router/utils.ts
@@ -0,0 +1,39 @@
+import type { RouterLocation } from './types.ts';
+
+/**
+ * Returns stub location.
+ * @returns Location.
+ * @internal
+ */
+export function getStubLocation(): RouterLocation {
+ return {
+ pathname: '/',
+ hash: '',
+ search: '',
+ };
+}
+
+/**
+ * Normalizes pathname in location or URL object.
+ * Does not mutate given object, returns new object.
+ * @param location Location (or URL object).
+ * @returns Location.
+ * @internal
+ */
+export function normalizeLocation(location: RouterLocation): RouterLocation {
+ return {
+ pathname: normalizePathname(location.pathname),
+ hash: location.hash,
+ search: location.search,
+ };
+}
+
+/**
+ * Normalizes pathname.
+ * @param pathname Pathname.
+ * @returns Normalized pathname.
+ * @internal
+ */
+export function normalizePathname(pathname: string): string {
+ return pathname.replace(/\/+$/, '') || '/';
+}