History
The history API in chayns-api gives you a structured navigation model on top of the browser history. Instead of treating the URL as one flat string, it splits navigation into layers. Each layer can own:
- route segments
- state
- query params
- a hash
- an optional active child layer
This makes it possible to model nested navigation cleanly, including hosted modules and iframes.
What problem this solves
Many chayns pages are not just a single screen. They often contain:
- multiple internal views
- overlays and state that should participate in back/forward navigation
- embedded modules
- embedded iframes
The history API lets each of these parts work with its own navigation scope while still projecting everything into the single browser history of the page.
Core concepts
Root layer
The root layer represents the main history scope of the current window.
You usually get it in one of two ways:
- Automatically through
ChaynsProvider - Manually through
initRootChaynsHistoryLayer()orgetOrInitRootChaynsHistoryLayer()
Child layers
A layer can have child layers. Only one child can be active at a time.
The active chain looks like this:
root -> shop -> product
Only the layers in that active chain contribute to the final URL and active state.
Segment ownership
Each layer owns a configurable number of path segments via segmentCount.
Example:
/shop/products/details
If:
rootowns1segment- child
catalogowns1segment - child
detailsowns1segment
then the effective routes are:
root:['shop']catalog:['products']details:['details']
State, params, and hash
Each layer also owns its own:
stateparamshash
When the URL is projected:
- route segments are concatenated along the active chain
- params are merged along the active chain
- the deepest explicitly set hash wins
Quick start
Use history in a normal page
Wrap your app with ChaynsProvider and enable history:
import { ChaynsProvider } from 'chayns-api';
export default function AppRoot() {
return (
<ChaynsProvider
isHistoryEnabled
history={{ segmentCount: 1 }}
>
<App />
</ChaynsProvider>
);
}
segmentCount defines how many path segments this layer claims initially.
For /shop/products, the example above would give the root layer:
['shop']
The remaining segments can later be claimed by child layers.
Read and write the current route
import { useChaynsHistoryRoute } from 'chayns-api';
export function ProductList() {
const { segments, setRoute } = useChaynsHistoryRoute();
return (
<>
<div>Current route: {segments.join('/')}</div>
<button onClick={() => void setRoute(['products'])}>
Open products
</button>
</>
);
}
Navigate route and state together
import { useChaynsHistoryNavigate } from 'chayns-api';
export function OpenProductButton() {
const navigate = useChaynsHistoryNavigate();
return (
<button
onClick={() =>
void navigate({
route: ['products', '42'],
state: { productId: 42 },
})
}
>
Open product
</button>
);
}
Available hooks
useChaynsHistoryLayer()
Returns the current layer instance.
Use this when you need the full imperative API.
import { useChaynsHistoryLayer } from 'chayns-api';
const layer = useChaynsHistoryLayer();
useChaynsHistoryRoute()
Returns:
segmentssetRoute(route, opts?)
Example:
const { segments, setRoute } = useChaynsHistoryRoute();
await setRoute(['settings']);
useChaynsHistoryState<T>()
Returns:
statesetState(state, opts?)
Example:
const [state, setState] = useChaynsHistoryState<{ tab?: string }>();
await setState({ tab: 'profile' });
useChaynsHistoryParams()
Returns:
paramssetParams(params, opts?)
Important: setParams() replaces the params of the current layer. Merge manually if needed.
const [params, setParams] = useChaynsHistoryParams();
await setParams({ ...params, filter: 'open' });
useChaynsHistoryHash()
Returns:
hashsetHash(hash, opts?)
const [hash, setHash] = useChaynsHistoryHash();
await setHash('details');
Use an empty string to clear the hash explicitly.
useChaynsHistoryNavigate()
Returns a single navigate() function that can update several parts atomically:
routestateparamshashactiveChildactiveChildInitisReplace
const navigate = useChaynsHistoryNavigate();
await navigate({
route: ['orders'],
params: { page: '2' },
hash: 'top',
state: { selectedId: 15 },
});
useChaynsHistoryActiveChild()
Returns:
activeChildIdsetActiveChild(id, init?)
const { activeChildId, setActiveChild } = useChaynsHistoryActiveChild();
await setActiveChild('details', { route: ['42'] });
useChaynsHistoryChildLayer(id)
Returns the child layer with the given id and creates it if necessary.
const detailsLayer = useChaynsHistoryChildLayer('details');
This is useful when you want to explicitly scope part of the UI to a dedicated child layer.
useChaynsHistoryBlock()
Registers a navigation blocker.
The callback must return:
trueto allow navigationfalseto block navigation
import { useCallback } from 'react';
import { useChaynsHistoryBlock } from 'chayns-api';
export function Editor({ isDirty }: { isDirty: boolean }) {
const confirmLeave = useCallback(async () => {
return !isDirty || window.confirm('Discard your changes?');
}, [isDirty]);
useChaynsHistoryBlock(confirmLeave, {
isEnabled: isDirty,
scope: 'local',
isBeforeUnload: true,
});
return null;
}
useChaynsHistoryEvent(type, handler)
Subscribes to low-level history events:
changepopstate
useChaynsHistoryEvent('popstate', (event) => {
console.log('Back/forward changed this layer', event);
});
Imperative layer API
A ChaynsHistoryLayer supports:
layer.getRoute();
layer.setRoute(route, opts);
layer.getState();
layer.setState(state, opts);
layer.getParams();
layer.setParams(params, opts);
layer.getHash();
layer.setHash(hash, opts);
layer.navigate(opts);
layer.createChildLayer(id);
layer.getChildLayer(id);
layer.setActiveChild(id, init);
layer.getActiveChildId();
layer.destroyChildLayer(id);
layer.getSegmentCount();
layer.setSegmentCount(count);
layer.addBlock(callback, opts);
layer.addEventListener('change', handler);
layer.addEventListener('popstate', handler);
Navigation behavior
pushState vs replaceState
Most write operations create a new browser history entry by default.
Use isReplace: true when you want to update the current entry instead:
await setRoute(['search'], { isReplace: true });
This is especially useful for:
- normalization
- initial synchronization
- filter changes that should not create a new entry
Active-chain restriction
Most operations only work on layers that are currently in the active chain.
If a layer exists but is not active anymore, write operations can resolve as stale and are ignored.
Atomic navigation
navigate() is the safest way to update several navigation properties at once because it commits them as one logical change.
Prefer:
await navigate({
route: ['checkout'],
state: { step: 2 },
params: { source: 'cart' },
});
over several separate calls when the values belong together.
Working with layers
Pattern: parent route with nested detail view
import {
useChaynsHistoryActiveChild,
useChaynsHistoryChildLayer,
ChaynsHistoryLayerOverrideProvider,
} from 'chayns-api';
function ProductPage() {
const { activeChildId, setActiveChild } = useChaynsHistoryActiveChild();
const detailsLayer = useChaynsHistoryChildLayer('details');
return (
<>
<button onClick={() => void setActiveChild('details', { route: ['42'] })}>
Open details
</button>
{activeChildId === 'details' && (
<ChaynsHistoryLayerOverrideProvider layer={detailsLayer}>
<ProductDetails />
</ChaynsHistoryLayerOverrideProvider>
)}
</>
);
}
ProductDetails now resolves all history hooks against the details layer instead of the parent layer.
When to use ChaynsHistoryLayerProvider vs ChaynsHistoryLayerOverrideProvider
ChaynsHistoryLayerProviderchanges both the React hook context and the module-level current-layer stackChaynsHistoryLayerOverrideProvideronly changes the React subtree context
In most application code, ChaynsHistoryLayerOverrideProvider is the safer choice for nested UI sections.
History in hosted modules
ChaynsHost can give an embedded module its own history layer.
Host side
import { ChaynsHost } from 'chayns-api';
<ChaynsHost
type="client-module"
isHistoryEnabled
historyChildId="orders"
system={{
scope: 'remote_app',
url: 'https://example.com/v2.remoteEntry.js',
module: './App',
}}
functions={functions}
pages={pages}
currentPage={currentPage}
isAdminModeActive={false}
site={site}
user={user}
device={device}
language={language}
parameters={parameters}
customData={customData}
environment={environment}
dialog={dialog}
/>
What this does:
ChaynsHostresolves a parent layer- it creates or reuses the child layer
orders - it passes that child layer into the hosted module
- the module can use history hooks inside its own namespace
Activating the module layer
Creating a child layer is not the same as activating it.
The parent layer must switch to that child:
await parentLayer.navigate({
activeChild: 'orders',
activeChildInit: {
route: ['list'],
state: { tab: 'open' },
},
});
This makes the child layer part of the active chain and therefore part of the URL.
Module side
Inside the hosted module, the normal hooks work without additional setup as long as the module is wrapped in ChaynsProvider.
import { ChaynsProvider, useChaynsHistoryRoute } from 'chayns-api';
function ModuleApp() {
const { segments, setRoute } = useChaynsHistoryRoute();
return (
<button onClick={() => void setRoute(['details', '42'])}>
Current: {segments.join('/')}
</button>
);
}
export default function AppWrapper(props) {
return (
<ChaynsProvider {...props}>
<ModuleApp />
</ChaynsProvider>
);
}
Important notes for modules
- History is opt-in. Set
isHistoryEnabled. - If you do not pass
historyChildId, the hosted content shares the parent layer. - A dedicated child layer is usually the better choice for reusable modules.
- The module only participates in navigation while its layer is active.
History in hosted iframes
ChaynsHost also supports history for hosted iframes.
Host side
import { ChaynsHost } from 'chayns-api';
<ChaynsHost
type="client-iframe"
src="https://example.com/app"
iFrameProps={{ name: 'myFrame' }}
isHistoryEnabled
historyChildId="embedded"
functions={functions}
pages={pages}
currentPage={currentPage}
isAdminModeActive={false}
site={site}
user={user}
device={device}
language={language}
parameters={parameters}
customData={customData}
environment={environment}
dialog={dialog}
/>
When history is enabled, the host exposes a history bridge to the iframe.
The iframe receives a FrameHistoryLayer proxy that:
- reads from a local cached snapshot
- forwards writes to the parent window
- receives
changeandpopstateupdates from the parent
Iframe side
Inside the iframe application, ChaynsProvider automatically picks up the bridged history layer from FrameWrapper.
That means regular hooks work directly:
import { ChaynsProvider, useChaynsHistoryNavigate } from 'chayns-api';
function IframeApp() {
const navigate = useChaynsHistoryNavigate();
return (
<button
onClick={() =>
void navigate({
route: ['wizard', 'step-2'],
state: { step: 2 },
})
}
>
Next step
</button>
);
}
export default function AppWrapper() {
return (
<ChaynsProvider>
<IframeApp />
</ChaynsProvider>
);
}
Important limitation in iframes
FrameHistoryLayer does not support child-layer creation inside the bridge layer.
This means the following is not supported on the bridged iframe layer:
layer.createChildLayer('x');
If you need deeper sub-routing inside the iframe, manage it locally inside the iframe with your own root history layer and provide it explicitly to the relevant subtree.
Example pattern:
import {
ChaynsHistoryLayerOverrideProvider,
getOrInitRootChaynsHistoryLayer,
useChaynsHistoryLayer,
} from 'chayns-api';
function EmbeddedSection() {
const outerLayer = useChaynsHistoryLayer(); // bridged host layer
const localRoot = getOrInitRootChaynsHistoryLayer(undefined, 1).rootLayer;
return (
<ChaynsHistoryLayerOverrideProvider layer={localRoot}>
<LocalRouter />
</ChaynsHistoryLayerOverrideProvider>
);
}
Use this pattern carefully. In most cases, the iframe should treat the bridged layer as its public navigation boundary and keep deeper view state internal.
Manual root initialization
If you need direct control outside the default provider flow:
import { initRootChaynsHistoryLayer } from 'chayns-api';
const { rootLayer } = initRootChaynsHistoryLayer({
url: '/shop/products?filter=open#details',
segmentCount: 1,
});
Options:
url: useful for SSRsegmentCount: how many path segments the root claims initially
There is also a singleton helper:
import { getOrInitRootChaynsHistoryLayer } from 'chayns-api';
const { rootLayer } = getOrInitRootChaynsHistoryLayer('/shop/products', 1);
Server-side rendering
For SSR, pass the current request URL when initializing the root history layer so the initial segments, params, and hash are correct on the first render.
With ChaynsProvider, this means:
<ChaynsProvider
isHistoryEnabled
history={{
url: req.url,
segmentCount: 1,
}}
>
<App />
</ChaynsProvider>
Blocking behavior
History blocks are checked before navigation is committed.
There are two scopes:
local: only blocks navigation targeting the same layerglobal: also blocks navigation targeting ancestor layers while this layer is on the active chain
Use global when a nested layer should be able to protect the whole active flow.
beforeunload
Set isBeforeUnload: true if the same condition should also prevent browser tab closes or reloads:
useChaynsHistoryBlock(confirmLeave, {
isEnabled: isDirty,
scope: 'global',
isBeforeUnload: true,
});
Events
Two event types exist:
change: emitted after a successful write operation on that layerpopstate: emitted when browser back/forward applies a different history entry
Example:
import { useChaynsHistoryEvent } from 'chayns-api';
useChaynsHistoryEvent('change', (event) => {
console.log(event.layerId, event.segments, event.state);
});
Event payload:
type ChaynsHistoryLayerEvent = {
type: 'change' | 'popstate';
layerId: string;
segments: string[];
state: Record<string, unknown>;
params: Record<string, string>;
hash: string;
};
Best practices
- Use
navigate()when route and state belong together. - Use dedicated child layers for hosted modules instead of sharing the root by default.
- Keep
segmentCountstable and intentional. - Use
isReplace: truefor synchronization and normalization steps. - Treat iframe history as a boundary layer; do not model deep child trees on the bridged frame layer.
- Use
globalblocks only when the nested flow really owns the whole leave decision.
Common pitfalls
History is not working at all
Most often, history was not enabled.
Check:
ChaynsProvider isHistoryEnabledChaynsHost isHistoryEnabled
Route hooks always return an empty array
Usually this means no layer claimed path segments yet.
Set a segmentCount on the relevant root or child layer.
A module or iframe does not affect the URL
Usually the hosted child layer exists, but is not active.
Make sure the parent navigates to:
await parentLayer.setActiveChild('child-id');
or:
await parentLayer.navigate({ activeChild: 'child-id' });
Query params disappear
setParams() replaces the current layer's params instead of merging them automatically.
Merge manually when needed.
Reserved state keys
Do not write these keys yourself:
activeChildchildState__params__hash
They are managed by the history core.