@thrylm/baqk
v0.1.4
Published
Smart back navigation with state preservation for React apps
Downloads
617
Maintainers
Readme
baqk
Smart back navigation with state preservation for React apps.
The Problem
User filters a list, clicks into a detail page, hits back — filters are gone. history.back() can't carry state, and sessionStorage alone doesn't know which page to restore. baqk solves this with a hybrid navId + sessionStorage approach that preserves state, scroll position, and navigation context across any number of levels.
Install
npm install @thrylm/baqkESM-only. Requires
react >= 18.
~3.5 kB gzipped (core + hook). Each adapter adds ~500 B.
Quick Start
// app.tsx — wrap your app with the adapter
import { BaqkAdapter } from "@thrylm/baqk/adapters/react-router";
function App() {
return (
<BaqkAdapter>
<Routes>{/* ... */}</Routes>
</BaqkAdapter>
);
}// products.tsx — listing page
import { useBaqk, useTrailClick } from "@thrylm/baqk";
import { Link } from "react-router-dom";
function ProductList() {
const { restoredState, saveState } = useBaqk<{ filters: Filters }>();
const trailClick = useTrailClick("Products");
// Restore filters synchronously — no useEffect
const [filters, setFilters] = useState(
() => restoredState?.filters ?? defaultFilters,
);
// Save state whenever filters change
useEffect(() => { saveState({ filters }); }, [filters]);
return products.map((p) => (
<Link to={`/products/${p.id}`} onClick={trailClick}>
{p.name}
</Link>
));
}// product-detail.tsx — detail page
import { useBaqk } from "@thrylm/baqk";
function ProductDetail() {
const { goBack, previousEntry } = useBaqk({
fallbackPath: "/products",
});
return (
<div>
<button onClick={() => goBack()}>
{previousEntry ? `← ${previousEntry.label}` : "← Products"}
</button>
{/* ... */}
</div>
);
}Adapters
React Router
import { BaqkAdapter } from "@thrylm/baqk/adapters/react-router";
<BaqkAdapter>
<RouterProvider router={router} />
</BaqkAdapter>Next.js App Router
import { BaqkAdapter } from "@thrylm/baqk/adapters/next";
<BaqkAdapter>
{children}
</BaqkAdapter>TanStack Router
import { BaqkAdapter } from "@thrylm/baqk/adapters/tanstack";
<BaqkAdapter>
<RouterProvider router={router} />
</BaqkAdapter>Generic (any router)
import { BaqkAdapter } from "@thrylm/baqk";
<BaqkAdapter
navigate={(path, options) =>
options?.replace ? myRouter.replace(path) : myRouter.push(path)
}
getCurrentPath={() => window.location.pathname + window.location.search}
>
{children}
</BaqkAdapter>All router-specific adapters accept optional sessionKey and storage props. The generic adapter additionally requires navigate and getCurrentPath.
API Reference
useTrailClick(label?)
Returns an onClick handler that pushes a trail entry without navigating. Attach it to same-tab internal <Link>/anchor navigations — the link handles navigation natively, no preventDefault needed.
import { useTrailClick } from "@thrylm/baqk";
function ProductList() {
const trailClick = useTrailClick("Products");
return products.map((p) => (
<Link to={`/products/${p.id}`} onClick={trailClick}>
{p.name}
</Link>
));
}Behavior:
- Saves scroll position and pushes the current page onto the trail
- Skips on modifier keys (
meta,ctrl,shift,alt), middle-click, ordefaultPrevented - Skips new-tab, download, external, and hash-only anchor clicks
- Captures path at click time via
getCurrentPath()(readswindow.location, compatible with nuqs/shallow updates) - Does NOT call
preventDefault()or navigate — the underlying link handles that - Shares the same
navIdasuseBaqk()(both useensureNavIdwhich is idempotent)
useBaqk<T>(options?)
Options (BaqkOptions)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| fallbackPath | string | undefined | Path to navigate to when goBack() is called with no trail |
| autoSaveScroll | boolean | true | Automatically save/restore scroll position |
Return value (BaqkResult<T>)
| Property | Type | Description |
|----------|------|-------------|
| goBack | (fallbackPath?) => void | Pop the trail and navigate back, or use fallback |
| previousEntry | TrailEntry \| null | The most recent trail entry (the page you'd go back to) |
| saveState | (state: T) => void | Save state for the current page |
| restoredState | T \| null | Synchronously available saved state (lazy ref pattern) |
| wasRestored | boolean | Whether state was restored for this page |
| clear | () => void | Clear the trail and all associated state |
TrailEntry
interface TrailEntry {
path: string;
navId: string;
label?: string;
timestamp: number;
}BaqkAdapterProps (router-specific adapters)
| Prop | Type | Description |
|------|------|-------------|
| children | ReactNode | — |
| sessionKey | string? | Namespace for storage isolation (e.g. user ID) |
| storage | StorageAdapter? | Custom storage backend (defaults to sessionStorage) |
GenericBaqkAdapterProps (generic adapter)
Extends BaqkAdapterProps with:
| Prop | Type | Description |
|------|------|-------------|
| navigate | (path: string, options?: { replace?: boolean }) => void | Navigation function |
| getCurrentPath | () => string | Returns current path + search params |
How It Works
- Each page visit gets a unique navId stamped into
history.state - When
useTrailClickfires, the current page's path + navId are pushed onto a trail stack in sessionStorage - State and scroll position are keyed by
sessionKey:navId, so they survive navigations goBackpops the trail, navigates to the previous path, and re-stamps the navId — triggering automatic state + scroll restorationrestoredStateis computed synchronously via a lazy ref (no useEffect, no flash of default state)
Advanced
Session key
Use sessionKey to isolate trails per user or session:
<BaqkAdapter sessionKey={user.id}>Custom storage
import { createMemoryStorage } from "@thrylm/baqk";
<BaqkAdapter storage={createMemoryStorage()}>createBaqkAdapter factory
Build an adapter for any router:
import { createBaqkAdapter } from "@thrylm/baqk";
const MyBaqkAdapter = createBaqkAdapter(() => {
// Return a RouterAdapter: { getCurrentPath, navigate, getHistoryState, replaceHistoryState }
return useMyRouter();
});Limits
- Trail stack: max 50 entries (oldest evicted with their state)
- State size: max 100 KB per entry (oversized state is silently dropped with a console warning)
License
MIT
