Adapters
Adapters bridge effector/router with browser history APIs, enabling URL synchronization and navigation. effector/router includes two built-in adapters and supports custom adapter creation.
Overview
An adapter translates between effector/router's internal navigation system and external history management libraries (like the history package). Adapters handle:
- Reading and updating URL location
- Managing browser history stack
- Listening to navigation events
- Providing back/forward navigation
Built-in Adapters
historyAdapter
Standard adapter for pathname-based navigation using the history library.
Use case: Traditional web navigation where routes are in the URL pathname.
Installation:
npm install historyBasic Usage:
import { createRouter, historyAdapter } from '@effector/router';
import { createBrowserHistory } from 'history';
const router = createRouter({
routes: [homeRoute, aboutRoute],
});
const history = createBrowserHistory();
router.setHistory(historyAdapter(history));
// Navigation changes URL pathname
aboutRoute.open();
// URL: /aboutWith Effector Scope:
import { allSettled, fork } from 'effector';
import { createBrowserHistory } from 'history';
import { historyAdapter } from '@effector/router';
const scope = fork();
const history = createBrowserHistory();
await allSettled(router.setHistory, {
scope,
params: historyAdapter(history),
});History Types:
// Browser History - Full URLs
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
// URL: http://localhost:3000/about
// Hash History - Static hosting
import { createHashHistory } from 'history';
const history = createHashHistory();
// URL: http://localhost:3000/#/about
// Memory History - Testing, SSR, React Native
import { createMemoryHistory } from 'history';
const history = createMemoryHistory({
initialEntries: ['/'],
initialIndex: 0,
});
// No URL changes, all in memoryReact Application Example:
import { createRoot } from 'react-dom/client';
import { Provider } from 'effector-react';
import { allSettled, fork } from 'effector';
import { createBrowserHistory } from 'history';
import { historyAdapter } from '@effector/router';
async function render() {
const scope = fork();
const history = createBrowserHistory();
await allSettled(router.setHistory, {
scope,
params: historyAdapter(history),
});
createRoot(document.getElementById('root')!).render(
<Provider value={scope}>
<RouterProvider router={router}>
<App />
</RouterProvider>
</Provider>,
);
}
render();queryAdapter
Specialized adapter that stores navigation state in URL query parameters instead of the pathname.
Use case: Modal routing, tabs, embedded apps, or secondary navigation where the main URL should remain constant.
Basic Usage:
import { createRouter, queryAdapter } from '@effector/router';
import { createBrowserHistory } from 'history';
const router = createRouter({
routes: [settingsModal, profileModal],
});
const history = createBrowserHistory();
router.setHistory(queryAdapter(history));
// Navigation changes query params, not pathname
settingsModal.open();
// URL: /app?%2FsettingsNavigation target (To):
Both adapters accept the same To value and interpret it identically — they differ only in where the target is stored:
type To = string | Partial<RouterLocation>;- A string is a full path, following the
historyconvention:pathname[?search][#hash](e.g.'/user/1?tab=info'). It is equivalent to the matching object form{ pathname: '/user/1', search: '?tab=info' }. - An object is a
Partial<RouterLocation>; omitted fields fall back to/(pathname) or empty strings.
By default queryAdapter stores the entire target path — pathname, search and hash together — URL-encoded into a single location.search value, while leaving the host pathname and hash untouched:
modalRouter.push('/user/1?tab=info');
// host URL: /users?%2Fuser%2F1%3Ftab%3Dinfo
// └ encodeURIComponent('/user/1?tab=info')Because this mode owns the whole search string, such a router and the host application cannot share other query parameters on the same URL.
Isolated query key ({ key }):
Pass a key to store the nested route in a single named query parameter instead of the whole search string. Every other query parameter on the host URL is preserved, so the router and the host application (or several queryAdapter routers) can coexist:
const modalRouter = createRouter({ routes: [userModal] });
modalRouter.setHistory(queryAdapter(history, { key: 'modal' }));
// host URL before: /users?sort=asc
userModal.open({ params: { id: '1' } });
// host URL after: /users?sort=asc&modal=%2Fuser%2F1
// └ the `sort` param is kept intactThe parameter's value is the nested route path, so slashes are percent-encoded (%2F) — the readable part is the key, not the slashes. Closing the route removes only that one parameter.
| Mode | Example URL | Coexists with other query params |
|---|---|---|
| Default (whole search) | /users?%2Fuser%2F1 | ❌ |
{ key: 'modal' } | /users?sort=asc&modal=%2Fuser%2F1 | ✅ |
Comparison:
| Feature | historyAdapter | queryAdapter |
|---|---|---|
| URL Location | Pathname | Query parameters |
| Example URL | /user/123 | /app?%2Fuser%2F123 |
| Use Case | Main navigation | Modal/tab navigation |
| SEO | ✅ Good | ⚠️ Limited |
Modal Routing Example:
// A single shared history — the modal layers on top of the main URL
const history = createBrowserHistory();
// Main router (pathname)
const mainRouter = createRouter({
routes: [homeRoute, aboutRoute],
});
mainRouter.setHistory(historyAdapter(history));
// Modal router (isolated query key)
const modalRouter = createRouter({
routes: [loginModal, settingsModal],
});
modalRouter.setHistory(queryAdapter(history, { key: 'modal' }));
// Navigate main route
aboutRoute.open();
// URL: /about
// Open modal — the main pathname stays, the modal lives in ?modal
loginModal.open();
// URL: /about?modal=%2Flogin
// Main route stays /about while modal changesBoth routers must share the same
historyinstance — that is how the query router layers its state on top of the main URL.
Tab Navigation Example:
const tabRouter = createRouter({
routes: [overviewTab, analyticsTab, settingsTab],
});
const history = createBrowserHistory();
tabRouter.setHistory(queryAdapter(history, { key: 'tab' }));
// Switch tabs
overviewTab.open();
// URL: /app?tab=%2Foverview
analyticsTab.open();
// URL: /app?tab=%2Fanalytics
// Back button works!
history.back();
// URL: /app?tab=%2FoverviewCustom Adapters
Create custom adapters to integrate with any navigation system.
Adapter Interface
interface RouterAdapter {
location: RouterLocation;
push: (to: To) => void;
replace: (to: To) => void;
goBack: () => void;
goForward: () => void;
listen: (callback: (location: RouterLocation) => void) => Subscription;
}
interface RouterLocation {
pathname: string;
search: string;
hash: string;
}
type To = string | Partial<RouterLocation>;Creating a Custom Adapter
Example 1: Console Logger Adapter
import type { RouterAdapter } from '@effector/router';
function consoleAdapter(): RouterAdapter {
let currentLocation = {
pathname: '/',
search: '',
hash: '',
};
const listeners = new Set<(location: RouterLocation) => void>();
const notify = () => {
listeners.forEach((listener) => listener(currentLocation));
};
return {
location: currentLocation,
push: (to) => {
if (typeof to === 'string') {
currentLocation = { pathname: to, search: '', hash: '' };
} else {
currentLocation = {
pathname: to.pathname ?? currentLocation.pathname,
search: to.search ?? currentLocation.search,
hash: to.hash ?? currentLocation.hash,
};
}
console.log('Navigate to:', currentLocation);
notify();
},
replace: (to) => {
if (typeof to === 'string') {
currentLocation = { pathname: to, search: '', hash: '' };
} else {
currentLocation = {
pathname: to.pathname ?? currentLocation.pathname,
search: to.search ?? currentLocation.search,
hash: to.hash ?? currentLocation.hash,
};
}
console.log('Replace with:', currentLocation);
notify();
},
goBack: () => {
console.log('Go back');
},
goForward: () => {
console.log('Go forward');
},
listen: (callback) => {
listeners.add(callback);
return {
unsubscribe: () => {
listeners.delete(callback);
},
};
},
};
}
// Use it
router.setHistory(consoleAdapter());Example 2: Local Storage Adapter
function localStorageAdapter(): RouterAdapter {
const STORAGE_KEY = 'router-location';
const getLocation = (): RouterLocation => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
return { pathname: '/', search: '', hash: '' };
};
const setLocation = (location: RouterLocation) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(location));
};
let currentLocation = getLocation();
const listeners = new Set<(location: RouterLocation) => void>();
const updateLocation = (to: To) => {
if (typeof to === 'string') {
currentLocation = { pathname: to, search: '', hash: '' };
} else {
currentLocation = {
pathname: to.pathname ?? currentLocation.pathname,
search: to.search ?? currentLocation.search,
hash: to.hash ?? currentLocation.hash,
};
}
setLocation(currentLocation);
listeners.forEach((listener) => listener(currentLocation));
};
return {
location: currentLocation,
push: updateLocation,
replace: updateLocation,
goBack: () => console.log('Back not supported'),
goForward: () => console.log('Forward not supported'),
listen: (callback) => {
listeners.add(callback);
return { unsubscribe: () => listeners.delete(callback) };
},
};
}Example 3: React Native Adapter
import { Linking } from 'react-native';
function reactNativeAdapter(): RouterAdapter {
let currentLocation: RouterLocation = {
pathname: '/',
search: '',
hash: '',
};
const listeners = new Set<(location: RouterLocation) => void>();
// Parse deep link URL
const parseUrl = (url: string): RouterLocation => {
try {
const parsed = new URL(url);
return {
pathname: parsed.pathname,
search: parsed.search,
hash: parsed.hash,
};
} catch {
return { pathname: url, search: '', hash: '' };
}
};
// Initialize with current URL
Linking.getInitialURL().then((url) => {
if (url) {
currentLocation = parseUrl(url);
}
});
// Listen to deep links
const subscription = Linking.addEventListener('url', ({ url }) => {
currentLocation = parseUrl(url);
listeners.forEach((listener) => listener(currentLocation));
});
return {
location: currentLocation,
push: (to) => {
const newLocation =
typeof to === 'string'
? parseUrl(to)
: {
pathname: to.pathname ?? currentLocation.pathname,
search: to.search ?? currentLocation.search,
hash: to.hash ?? currentLocation.hash,
};
currentLocation = newLocation;
// Update React Native navigation
const url = `myapp://${newLocation.pathname}${newLocation.search}${newLocation.hash}`;
Linking.openURL(url);
listeners.forEach((listener) => listener(currentLocation));
},
replace: (to) => {
// Same as push for React Native
this.push(to);
},
goBack: () => {
// Handle via React Navigation or custom logic
},
goForward: () => {
// Not typically supported in mobile
},
listen: (callback) => {
listeners.add(callback);
return {
unsubscribe: () => {
listeners.delete(callback);
subscription.remove();
},
};
},
};
}Example 4: Electron IPC Adapter
import { ipcRenderer } from 'electron';
function electronAdapter(): RouterAdapter {
let currentLocation: RouterLocation = {
pathname: '/',
search: '',
hash: '',
};
const listeners = new Set<(location: RouterLocation) => void>();
// Listen to navigation from main process
ipcRenderer.on('navigate', (_, location: RouterLocation) => {
currentLocation = location;
listeners.forEach((listener) => listener(currentLocation));
});
return {
location: currentLocation,
push: (to) => {
const newLocation =
typeof to === 'string'
? { pathname: to, search: '', hash: '' }
: {
pathname: to.pathname ?? currentLocation.pathname,
search: to.search ?? currentLocation.search,
hash: to.hash ?? currentLocation.hash,
};
currentLocation = newLocation;
// Send to main process
ipcRenderer.send('router-navigate', newLocation);
listeners.forEach((listener) => listener(currentLocation));
},
replace: (to) => {
// Same as push for Electron
this.push(to);
},
goBack: () => {
ipcRenderer.send('router-back');
},
goForward: () => {
ipcRenderer.send('router-forward');
},
listen: (callback) => {
listeners.add(callback);
return {
unsubscribe: () => {
listeners.delete(callback);
},
};
},
};
}Adapter Requirements
When creating a custom adapter, ensure:
1. Initial Location
Provide initial location when created:
return {
location: {
pathname: '/',
search: '',
hash: '',
},
// ...
};2. Handle String and Object Navigation
Support both formats. Per the To contract a string is a full path (pathname[?search][#hash]), not just a pathname — parse it (e.g. with parsePath from the history package) so '/about?id=1#top' is handled the same as its object form:
import { parsePath } from 'history';
push: (to) => {
if (typeof to === 'string') {
// '/about?id=1#top' → { pathname: '/about', search: '?id=1', hash: '#top' }
const { pathname, search, hash } = parsePath(to);
navigate({
pathname: pathname ?? current.pathname,
search: search ?? '',
hash: hash ?? '',
});
} else {
// Handle object: { pathname: '/about', search: '?id=1' }
navigate({
pathname: to.pathname ?? current.pathname,
search: to.search ?? current.search,
hash: to.hash ?? current.hash,
});
}
};3. Notify Listeners
Call all listeners when location changes:
const listeners = new Set<(location: RouterLocation) => void>();
const notify = () => {
listeners.forEach((listener) => listener(currentLocation));
};
// After navigation
push: (to) => {
// ... update location
notify();
};4. Return Unsubscribe Function
The listen method must return an object with unsubscribe:
listen: (callback) => {
listeners.add(callback);
return {
unsubscribe: () => {
listeners.delete(callback);
// Cleanup any resources
},
};
};5. Maintain Location State
Keep location property synchronized:
const adapter = {
location: currentLocation, // Always current
push: (to) => {
currentLocation = newLocation;
this.location = currentLocation; // Update reference
notify();
},
};Testing Adapters
Test with Memory History
import { createMemoryHistory } from 'history';
import { historyAdapter } from '@effector/router';
import { allSettled, fork } from 'effector';
test('navigation works', async () => {
const scope = fork();
const history = createMemoryHistory({ initialEntries: ['/'] });
await allSettled(router.setHistory, {
scope,
params: historyAdapter(history),
});
await allSettled(aboutRoute.open, { scope });
expect(history.location.pathname).toBe('/about');
expect(scope.getState(aboutRoute.$isOpened)).toBe(true);
});Test Custom Adapter
test('custom adapter', async () => {
const locations: RouterLocation[] = [];
const mockAdapter: RouterAdapter = {
location: { pathname: '/', search: '', hash: '' },
push: (to) => {
const location =
typeof to === 'string'
? { pathname: to, search: '', hash: '' }
: { pathname: to.pathname ?? '/', search: '', hash: '' };
locations.push(location);
},
replace: vi.fn(),
goBack: vi.fn(),
goForward: vi.fn(),
listen: () => ({ unsubscribe: () => {} }),
};
const scope = fork();
await allSettled(router.setHistory, { scope, params: mockAdapter });
await allSettled(aboutRoute.open, { scope });
expect(locations).toContainEqual({
pathname: '/about',
search: '',
hash: '',
});
});Best Practices
Use Built-in Adapters When Possible
// ✅ Recommended for web apps
router.setHistory(historyAdapter(createBrowserHistory()));
// ✅ Recommended for modals/tabs (share the host's history instance)
modalRouter.setHistory(queryAdapter(history, { key: 'modal' }));
// ⚠️ Only create custom adapters when necessary
router.setHistory(customAdapter());Initialize Early
Set adapter before any navigation:
// ✅ Good
await allSettled(router.setHistory, { scope, params: adapter });
await allSettled(homeRoute.open, { scope });
// ❌ Bad
await allSettled(homeRoute.open, { scope });
await allSettled(router.setHistory, { scope, params: adapter });Single Adapter Instance
Create only one adapter per router:
// ✅ Good
const adapter = historyAdapter(createBrowserHistory());
router.setHistory(adapter);
// ❌ Bad
router.setHistory(historyAdapter(createBrowserHistory()));
router.setHistory(historyAdapter(createBrowserHistory())); // Different instanceClean Up Resources
Ensure proper cleanup in custom adapters:
listen: (callback) => {
listeners.add(callback);
// Setup subscriptions
const subscription = externalLibrary.subscribe(callback);
return {
unsubscribe: () => {
listeners.delete(callback);
subscription.unsubscribe(); // ✅ Cleanup
},
};
};API Reference
historyAdapter(history: History): RouterAdapter
Creates a standard pathname-based adapter.
Parameters:
history: History- History instance fromhistorypackage
Returns: RouterAdapter
queryAdapter(history: History, options?: { key?: string }): RouterAdapter
Creates a query parameter-based adapter.
Parameters:
history: History- History instance fromhistorypackageoptions.key?: string- When set, the nested route is stored in a single query parameter with this name (e.g.?modal=%2Fuser%2F1) and all other query parameters are preserved. When omitted, the adapter owns the wholelocation.search(e.g.?%2Fuser%2F1).
Returns: RouterAdapter
Types
interface RouterAdapter {
location: RouterLocation;
push: (to: To) => void;
replace: (to: To) => void;
goBack: () => void;
goForward: () => void;
listen: (callback: (location: RouterLocation) => void) => Subscription;
}
interface RouterLocation {
pathname: string;
search: string;
hash: string;
}
type To = string | Partial<RouterLocation>;
interface Subscription {
unsubscribe: () => void;
}See Also
- createRouter - Create a router with adapters
- createRouterControls - Create navigation controls
- trackQuery - Track query parameters