Handles navigation in a web browser. Represents web browser navigation history as a "stack" data structure. Provides operations to perform programmatic navigation such as "push" (go to new URL), "replace" (redirect to new URL), "shift" (rewind to a previously visited URL). Provides a subscription mechanism to get notified on location changes.
Also supports automatic scroll position restoration on "Back"/"Forward" navigation.
Originally forked from farce
package to fix a couple of small bugs (1, 2). Then merged it with scroll-behavior
package to fix a couple of small bugs (1, 2). Then decided to completely rewrite the entire code and changed the API to my liking.
npm install navigation-stack
Any changes to a NavigationStack
instance are "magically" reflected in the web browser's address bar and navigation history, and vice versa: any changes to the URL in the web browser's address bar are "magically" reflected in the NavigationStack
instance. So one could think of NavigationStack
as a very convenient proxy to web browser's address bar and navigation history. What's left to the application is to subscribe to navigationStack
changes and re-render the page accordingly.
Start by creating a NavigationStack
instance.
import { NavigationStack, WebBrowserSession } from 'navigation-stack'
// Create a `NavigationStack` instance.
// It should be tied to a navigation "session".
const navigationStack = new NavigationStack(new WebBrowserSession())
Then subscribe to changes:
// Subscribe to location changes.
// The first call happens for the initial location.
// Next calls will happen in case of navigation.
const unsubscribe = navigationStack.subscribe((location) => {
console.log('Current location', location)
document.body.innerHTML = '<div>' + location.pathname + '</div>'
})
Now ready to perform navigation actions.
// Sets the initial location.
navigationStack.init()
// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Also adds a new entry in the web browser's navigation history.
//
navigationStack.push('/new-location')
// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Does not add a new entry in the web browser's navigation history
// which is the only difference between this and `Actions.push()`.
//
navigationStack.replace('/new-location')
// Sets the `location` to be a previous one (if there is one).
// If there's no such `location` in the navigation history,
// throws a `NavigationOutOfBoundsError` error that has an `index` property.
//
// One could think of it as an equivalent of clicking a "Back" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
navigationStack.shift(-1)
// Sets the `location` to be a next one (if there is one).
// If there's no such `location` in the navigation history,
// throws a `NavigationOutOfBoundsError` error that has an `index` property.
//
// One could think of it as an equivalent of clicking a "Forward" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
navigationStack.shift(1)
To get the current location:
const location = navigationStack.current()
console.log(location)
(optional) After the user is done using the app, stop the session and clean up any listeners.
// (optional)
// When the user closes the application,
// stop the session and clean up any listeners.
// There's no need to do this in a web browser.
unsubscribe()
navigationStack.stop()
To get the current location, use navigationStack.current()
.
Current location
object has all the properties of a standard web browser location with the addition of:
query: object
— URL query parameters.key: string
— A string ID of the location that is guaranteed to be unique within the session's limits and could be used as a "key" to store any supplementary data associated to this location.index: number
— The index of the location in the navigation stack, starting with0
for the initial location.
Pass maintainScrollPosition: true
option to keep track of scroll position on every page and then automatically restore it on "Back" or "Forward" navigation.
import { NavigationStack, WebBrowserSession } from 'navigation-stack'
// Create a `NavigationStack` instance with a `maintainScrollPosition: true` option.
const navigationStack = new NavigationStack(new WebBrowserSession(), {
maintainScrollPosition: true
})
//----------------------------------------------------------------------------------------
// Sets the initial location.
navigationStack.init()
// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'
// As soon as a page has been rendered, without any delay, tell `NavigationStack` to restore
// a previously-saved scroll position, if there's any.
//
// This method must be called both for the initial location and any subsequent location.
//
navigationStack.locationRendered()
//----------------------------------------------------------------------------------------
// Set the `location` to be a new location.
//
// This also updates the URL in the web browser's address bar
// and adds a new entry in the web browser's navigation history.
//
navigationStack.push('/new-location')
// Render the new location.
document.body.innerHTML = '<div> New Location </div>'
// The new location is now rendered.
// Immediately after it has been rendered, call `.locationRenered()`.
// There's no scroll position to restore because it's not a previously-visited location.
navigationStack.locationRendered()
//----------------------------------------------------------------------------------------
// Set `location` "back" to the initial location.
//
// This also updates the URL in the web browser's address bar
// and repositions the "current location" pointer in the web browser's navigation history.
//
navigationStack.shift(-1)
// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'
// The initial location is now rendered.
// Immediately after it has been rendered, call `.locationRenered()`.
// Restores the scroll position at the initial location.
navigationStack.locationRendered()
//----------------------------------------------------------------------------------------
// (optional)
// When the user is about to close the application,
// stop the `NavigationStack` and clean up any of its listeners.
// This is not required in a web browser because it cleans up all listeners
// automatically when closing a tab.
navigationStack.stop()
NavigationStack
provides methods:
addScrollableContainer(key: string, element: Element)
— Use it in cases when it should restore not only the page scroll position but also the scroll position(s) of any other scrollable container(s). Returns a "remove scrollable container" function.locationRendered()
— Call it every time a different location has been rendered, including the initial location, without any delay, i.e. immediately after a different location has been rendered.
Using scroll position restoration feature without NavigationStack
To use the scroll position restoration feature independently of a NavigationStack
instance (e.g. without it), create a ScrollPositionRestoration
instance and pass a session
argument to it.
import { WebBrowserSession } from 'navigation-stack'
import { ScrollPositionRestoration } from 'navigation-stack/scroll-position'
// Create a `ScrollPositionRestoration`.
const scrollPositionRestoration = new ScrollPositionRestoration(new WebBrowserSession())
//----------------------------------------------------------------------------------------
// If you decide to use `NavigationStack` or Redux-way `createMiddlewares()` for navigation,
// it should be tied to the same session.
//
// const navigationStack = new NavigationStack(session)
// navigationStack.init()
//
// Or, navigation could be performed by any other means such as using `window.history.pushState()`.
// The only requirement is for the "current location" object to have a `key` property
// which the standard `window.location` object doesn't provide.
//
window.history.replaceState({ key: '123' }, '', '/initial-location')
// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'
// Immediately after a page has been rendered, without any delay,
// call `.locationRendered()` method with the "current location" object as the argument.
scrollPositionRestoration.locationRendered({ key: '123', pathname: '/initial-location' })
//----------------------------------------------------------------------------------------
// Navigate to a new location.
//
// For example, it could use `NavigationStack` for navigation.
//
// navigationStack.push('/new-location')
//
// Or, navigation could be performed by any other means such as using `window.history.pushState()`.
//
window.history.pushState({ key: '456' }, '', '/new-location')
// Render the new location.
document.body.innerHTML = '<div> New Location </div>'
// The new location is now rendered.
// Call `.locationRendered()` immediately after it has been rendered, i.e. without any delay.
// There's no scroll position to restore because it's not a previously-visited location.
// The "current location" object must have a `key`.
scrollPositionRestoration.locationRendered({ key: '456', pathname: '/new-location' })
//----------------------------------------------------------------------------------------
// Navigate "back" to the initial location.
//
// For example, it could use `NavigationStack` for navigation.
//
// navigationStack.shift(-1)
//
// Or, navigation could be performed by any other means such as using `window.history.go()`.
//
window.history.go(-1)
// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'
// The initial location is now rendered.
// Call `.locationRendered()` immediately after it has been rendered, i.e. without any delay.
// It will restore the scroll position at the initial location.
// The "current location" object must have a `key`.
scrollPositionRestoration.locationRendered({ key: '123', pathname: '/initial-location' })
//----------------------------------------------------------------------------------------
// (optional)
// When the user is about to close the application,
// stop the `ScrollPositionRestoration` and clean up any of its listeners.
// This is not required in a web browser because it cleans up all listeners
// automatically when closing a tab.
scrollPositionRestoration.stop()
// (optional)
// In case of using `NavigationStack` for navigation,
// stop the `NavigationStack` and clean up any of its listeners.
//
// navigationStack.stop()
ScrollPositionRestoration
provides methods:
addScrollableContainer(key: string, element: Element)
— Use it in cases when it should restore not only the page scroll position but also the scroll position(s) of any other scrollable container(s). Returns a "remove scrollable container" function.locationRendered(location)
— Call it every time a different location has been rendered, including the initial location, without any delay, i.e. immediately after a different location has been rendered. The location argument must have akey
.stop()
— Stops scroll position restoration and clears any listeners or timers.
If the web application is hosted under a certain URL prefix, it should be specified as a basePath
parameter when creating a NavigationStack
instance. This prefix will automatically be added to the URL in the web browser's address bar while the location
object itself won't include it in the pathname
.
new NavigationStack(new WebBrowserSession(), { basePath: '/base-path' })
A "session" ties NavigationStack
to the environment it operates in, such as a web browser.
Three different "session" implementations are shipped with this package:
- Use
WebBrowserSession
in a web browser. Such session survives a page refresh and is automatically destroyed when the web browser tab gets closed. - Use
ServerSideRenderSession
in server-side rendering. Create a separate session for each incoming HTTP request. Initialize it with a relative URL of the HTTP request. If, during server-side render, the application code attempts to navigate to another location, it will throw aServerSideNavigationError
with alocation
property in it. - Use
InMemorySession
in tests to mimick aWebBrowserSession
. Create a separate session for each separate navigation session. Initialize it with a relative URL or a location object.
See ServerSideRenderSession
example
const navigationStack = new NavigationStack(new ServerSideRenderSession())
navigationStack.subscribe((location) => {
console.log('Current location', location)
})
// Sets the initial location.
// Triggers the subscription listener.
navigationStack.init('/initial-location')
// Navigates to a new location.
// Throws `ServerSideNavigationError` with a `location` property.
navigationStack.push('/new-location')
See InMemorySession
example
const navigationStack = new NavigationStack(new InMemorySession())
navigationStack.subscribe((location) => {
console.log('Current location', location)
})
// Sets the initial location.
// Triggers the subscription listener.
navigationStack.init('/initial-location')
// Navigates to a new location.
// Triggers the subscription listener.
navigationStack.push('/new-location')
Every "session" has a unique key
.
Once created, a "session" is simply passed to the NavigationStack
constructor and then you don't have to deal with it anymore — NavigationStack
will pull all the strings for you.
However, if someone prefers to completely bypass NavigationStack
and interact with a "session" object directly, they could do so.
See "session" API
key: string
— A unique ID of the session.subscribe(listener: (location) => {}): () => {}
— Subscribes to location changes, including setting the initial location. Returns an "unsubscribe" function. Thelocation
argument of the listener function is an "extended" location object having additional properties:operation: string
— The type of navigation that led to the location.INIT
in case of the initial location before any navigation has taken place.SHIFT
when the user performs a "Back" or "Forward" navigation, or after a.shift()
navigation which is essentially a "back or forward navigation".PUSH
in case of a.push()
navigation, i.e. "normal navigation via a hyperlink".REPLACE
in case of a.replace()
navigation, i.e. "redirect".
delta: number
— the difference between theindex
of the current location and theindex
of the previous location.0
for the initial location before any navigation has taken place.1
after a.push()
navigation, i.e. "normal navigation via a hyperlink".0
after a.replace()
navigation, i.e. "redirect".delta: number
after a.shift(delta)
navigation, i.e. "back or forward navigation".-1
after the user clicks a "Back" button in their web browser.1
after the user clicks a "Forward" button in their web browser.
start(initialLocation?: object)
— Starts the session. TheinitialLocation
argument is optional when the session can read it from somewhere. For example,WebBrowserSession
can readinitialLocation
fromwindow.location
.stop()
— Stops the session. Cleans up any listeners, etc.navigate(operation: string, location: object)
— Navigates to alocation
using either"PUSH"
or"REPLACE"
operation. Thelocation
argument should be a result of callingparseInputLocation()
function.shift(delta: number)
— Navigates "back" or "forward" by skipping a specified count of pages. Negativedelta
skips backwards, positivedelta
skips forward.
This package exports a few utility functions for transforming locations.
import {
getLocationUrl,
parseLocationUrl,
parseInputLocation,
addBasePath,
removeBasePath
} from 'navigation-stack'
// The following two are "mutually inverse functions":
// one maps a `location` object to a URL string
// and the other maps a URL string to a `location` object.
// Converts a location object to a location URL.
getLocationUrl({ pathname: '/abc', search: '?d=e' }) === '/abc?d=e'
// Parses a location URL to a location object.
// If there're no query parameters, `query` property will be an empty object.
parseLocationUrl('/abc?d=e') === {
pathname: '/abc',
search: '?d=e',
query: { d: 'e' },
hash: ''
}
// The following function parses a non-strict location object to a strict one.
// It also parses a location URL to a location object.
parseInputLocation({ pathname: '/abc', search: '?d=e' }) === {
pathname: '/abc',
search: '?d=e',
query: { d: 'e' },
hash: ''
}
parseInputLocation('/abc?d=e') === {
pathname: '/abc',
search: '?d=e',
query: { d: 'e' },
hash: ''
}
// The following two functions can be used to add base path to a location
// or to remove it from it.
// Adds `basePath` to a location object or a location URL.
addBasePath('/abc', '/base-path') === '/base-path/abc'
addBasePath({ pathname: '/abc' }, '/base-path') === { pathname: '/base-path/abc' }
// Removes `basePath` from a location object or a location URL.
// If `basePath` is not present in location, it won't do anything.
removeBasePath('/base-path/abc', '/base-path') === '/abc';
removeBasePath({ pathname: '/base-path/abc' }, '/base-path') === { pathname: '/abc' }
navigation-stack
provides the ability to block navigation. Call addNavigationBlocker()
function to set up a "navigation blocker".
import {
NavigationStack,
WebBrowserSession,
addNavigationBlocker
} from 'navigation-stack'
// Create a session.
const session = new WebBrowserSession()
// Create a `NavigationStack` instance.
const navigationStack = new NavigationStack(session)
// Add a navigation blocker.
// It should be tied to the same "session".
const removeNavigationBlocker = addNavigationBlocker(
session,
(newLocation) => {
// Returning `true` means "this navigation should be blocked".
return true
}
);
// Because the navigation is blocked, current location will not change here.
//
// The URL in the web browser's address bar will stay the same
// and no new entries will be added in the web browser's navigation history.
//
navigationStack.push('/new-location')
// Remove the navigation blocker.
removeNavigationBlocker()
// With the blocker removed, current location will be set to a new one.
//
// This also updates the URL in the web browser's address bar
// and adds a new entry in the web browser's navigation history.
//
navigationStack.push('/new-location')
Navigation blocker should be a function that receives a newLocation
argument and could be "synchronous" or "asynchronous" (i.e. return a Promise
, aka async
/await
).
The newLocation
argument of a blocker function might not necessarily have a key
or index
property but other properties are present.
Navigation blockers fire both when navigating from one page to another and when closing the current browser tab. In the latter case, newLocation
argument will be null
, and also the blocker function can't return a Promise
(because it won't wait), and returning true
from it will cause the web browser will to show a confirmation modal with a non-customizable generic browser-specific text like "Leave site? Changes you made might not be saved".
One could use DataStorage
to store any kind of application-specific data in a given "session". The data will exist as long as the "session" exists.
Different types of data could be stored under a different key
.
If each different location should have it's own data stored under the same key
, one could use LocationDataStorage
instead of just DataStorage
. For example, one could store scroll position for each different page to be able to restore it when the user decides to navigate "Back" to that page. By the way, that's precisely what ScrollPositionRestoration
does.
DataStorage
constructor receives a session
argument and a namespace
parameter. The namespace
just gets prepended to every key
. The idea is that your namespace
must not clash with anyone else's namespace
who might potentially use the same session
to store their own data.
import { WebBrowserSession } from 'navigation-stack'
import { DataStorage, LocationDataStorage } from 'navigation-stack/data-storage'
const session = new WebBrowserSession()
// `DataStorage` example
const dataStorage = new DataStorage(session, { namespace: 'my-namespace' })
dataStorage.set('key', 123)
dataStorage.get('key') === 123
// `LocationDataStorage` example
const locationDataStorage = new LocationDataStorage(session, { namespace: 'my-namespace' })
const location = { pathname: '/abc' }
locationDataStorage.set(location, 'key', 123)
locationDataStorage.get(location, 'key') === 123
DataStorage
or LocationDataStorage
don't provide any guarantees about actually storing the data: if it encounters any errors in the process, it simply ignores them. This simplifies the API in a way that the application doesn't have to wrap .get()
/.set()
calls in a try/catch
block. And judging by the nature of location-specific data, that type of data is inherently non-essential and rather "nice-to-have".
One might ask: Why use DataStorage
or LocationDataStorage
when one could simply store the data in a usual variable? The answer is that a usual variable doesn't survive if the user decides to refresh the page. But the entire navigation history does survive because that's how web browsers work. So if the user decides to go "Back" after refreshing the current page, the data associated to that previous location would already be lost and can't be recovered. In contrast, when using a DataStorage
or LocationDataStorage
with a WebBrowserSession
, the stored data does survive a page refresh, which feels more consistent and coherent with the persistence behavior of the navigation history itself.
Under the hood, navigation-stack
uses redux
. Why? For no particular reason. The original farce
package was published in September 2016, and by that time redux
had still been a hot topic since July 2025. This package could most certainly be rewritten without using redux
, it's just that there seems to be no need to do that.
So since navigation-stack
already implements all that redux
stuff internally, such as "middlewares" or "actions", why not export it for public usage? Maybe there're still some redux
fans out there.
Using navigation-stack
redux
-way is equivalent to using it the conventional way via NavigationStack
class. navigation-stack
exports "middlewares", "actions" and a "reducer" that could be used in conjunction with redux
or any other redux
-compatible package (e.g. mini-redux
).
See redux
-style API
Start by creating a Redux "store" with navigation-stack
middlewares.
import { createStore, applyMiddleware } from 'redux';
import {
createMiddlewares,
locationReducer,
Actions,
WebBrowserSession,
} from 'navigation-stack';
// Create a Redux store.
const store = createStore(
// Reducer function. For example, `locationReducer()`.
locationReducer,
// It should be tied to a navigation "session".
applyMiddleware(...createMiddlewares(new WebBrowserSession())),
);
Next, set the initial location. Normally, NavigationStack
class API automatically performs this step for a developer. When using Redux API though, it doesn't do that and the developer has to do it themself.
// Sets the initial `location`.
//
// Accepts either a relative URL string or a location object.
//
// The initial location argument could be omitted for `WebBrowserSession`
// because it can read it by itself from `window.location`.
// Other types of session such as `InMemorySession` or `ServerSideRenderSession`
// don't have an initial location and require the initial location argument
// to be specified explicitly when creating an `Actions.init(initialLocation)` action.
//
store.dispatch(Actions.init());
Then subscribe to location changes. One could use Redux'es standard subscription mechanisms to immediately get notified of current location changes.
let currentLocation;
// Create a Redux store.
const store = createStore(
locationReducer, // Reducer function. For example, `locationReducer()`.
applyMiddleware(...createMiddlewares(new WebBrowserSession())),
);
// Subscribe to any potential Redux state changes.
const unsubscribe = store.subscribe(() => {
const previousLocation = currentLocation;
currentLocation = store.getState(); // In case of using `locationReducer()`.
if (currentLocation !== previousLocation) {
console.log('Current location', currentLocation);
}
});
Now ready to perform navigation actions by dispatching any of the available Actions
.
// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Also adds a new entry in the web browser's navigation history.
//
store.dispatch(Actions.push('/new-location'));
// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Does not add a new entry in the web browser's navigation history
// which is the only difference between this and `Actions.push()`.
//
store.dispatch(Actions.replace('/new-location'));
// Sets the `location` to be a previous one (if there is one).
// One could think of it as an equivalent of clicking a "Back" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
store.dispatch(Actions.shift(-1));
// Sets the `location` to be a next one (if there is one).
// One could think of it as an equivalent of clicking a "Forward" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
store.dispatch(Actions.shift(1));
To get the current location:
// When `locationReducer()` is used, `store.getState()` returns the current location.
const location = store.getState();
console.log(location);
(optional) After the user is done using the app, stop the session and clean up any listeners.
// (optional)
// When the user closes the application,
// stop the session and clean up any listeners.
// There's no need to do this in a web browser.
unsubscribe();
store.dispatch(Actions.stop());
Clone the repository. Then:
yarn
yarn format
yarn test
It runs tests in two web browsers (for no particular reason) — Chrome and Firefox (configurable in karma.conf.cjs
). When running yarn test
, it opens Chome and Firefox browser windows. Don't unfocus those windows, otherwise the tests will finish with errors.
On March 9th, 2020, GitHub, Inc. silently banned my account (erasing all my repos, issues and comments, even in my employer's private repos) without any notice or explanation. Because of that, all source codes had to be promptly moved to GitLab. The GitHub repo is now only used as a backup (you can star the repo there too), and the primary repo is now the GitLab one. Issues can be reported in any repo.