Skip to main content

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:

  1. Automatically through ChaynsProvider
  2. Manually through initRootChaynsHistoryLayer() or getOrInitRootChaynsHistoryLayer()

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:

  • root owns 1 segment
  • child catalog owns 1 segment
  • child details owns 1 segment

then the effective routes are:

  • root: ['shop']
  • catalog: ['products']
  • details: ['details']

State, params, and hash​

Each layer also owns its own:

  • state
  • params
  • hash

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>
</>
);
}
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:

  • segments
  • setRoute(route, opts?)

Example:

const { segments, setRoute } = useChaynsHistoryRoute();
await setRoute(['settings']);

useChaynsHistoryState<T>()​

Returns:

  • state
  • setState(state, opts?)

Example:

const [state, setState] = useChaynsHistoryState<{ tab?: string }>();
await setState({ tab: 'profile' });

useChaynsHistoryParams()​

Returns:

  • params
  • setParams(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:

  • hash
  • setHash(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:

  • route
  • state
  • params
  • hash
  • activeChild
  • activeChildInit
  • isReplace
const navigate = useChaynsHistoryNavigate();

await navigate({
route: ['orders'],
params: { page: '2' },
hash: 'top',
state: { selectedId: 15 },
});

useChaynsHistoryActiveChild()​

Returns:

  • activeChildId
  • setActiveChild(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:

  • true to allow navigation
  • false to 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:

  • change
  • popstate
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);

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​

  • ChaynsHistoryLayerProvider changes both the React hook context and the module-level current-layer stack
  • ChaynsHistoryLayerOverrideProvider only 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:

  1. ChaynsHost resolves a parent layer
  2. it creates or reuses the child layer orders
  3. it passes that child layer into the hosted module
  4. 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 change and popstate updates 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 SSR
  • segmentCount: 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 layer
  • global: 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 layer
  • popstate: 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​

  1. Use navigate() when route and state belong together.
  2. Use dedicated child layers for hosted modules instead of sharing the root by default.
  3. Keep segmentCount stable and intentional.
  4. Use isReplace: true for synchronization and normalization steps.
  5. Treat iframe history as a boundary layer; do not model deep child trees on the bridged frame layer.
  6. Use global blocks 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 isHistoryEnabled
  • ChaynsHost 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:

  • activeChild
  • childState
  • __params
  • __hash

They are managed by the history core.