tamer-router
File-based routing for Lynx with React Router 6 + TamerNav native stack coordination. Covers file-based route generation, hardware back handling, and cross-spoke state synchronization.
Overview
- File-based routing: Rsbuild plugin scans
pages/→ generates route tree. Conventions:index→ index route,[param]→ dynamic segment,_layout.tsx→ layout wrapper. - Native stack:
FileRouterpushes spoke LynxViews viaTamerNavfrom@tamer4lynx/tamer-navigation. Coordinator manages route stack. - Layout components:
Stack/Tabswith AppBar, TabBar, Content (via tamer-app-shell). - State bridging:
providerConnectorprop syncs React Context / hooks across spoke boundaries. Required if your app uses any React providers (Zustand, Redux, TanStack Query, i18n, theme, etc.) — each spoke gets a fresh JS context and won't inherit the coordinator's provider state without this. See Cross-spoke state below. - Hardware back:
BackHandlerProvider+useBackHandlerintercept Android back / iOS gesture. - Manual coordinator: Don't need
FileRouter? UseTamerNavdirectly from@tamer4lynx/tamer-navigationwithBackHandlerProviderfor back handling. The example app'ssrc/example_stack.tsxshows this pattern — a hand-rolled coordinator that callsTamerNav.push/TamerNav.popwithout any file-based routing.tamer-routeris a higher-level solution built on top of the same primitives.
tamer-router has gone through a significant internal refactor. If you are one of the handful of people already using it — you know who you are — the public API is the same but the internals are cleaner. If it breaks, please file an issue.
Installation
Peers: react-router@^6. Run t4l link after install.
Setup
1. Rsbuild config
Use tamer-plugin (applies default tamer-router config automatically):
Or configure tamerRouterPlugin directly:
2. Entry point
Optional: pass providerConnector to bridge state across spokes (see Cross-spoke state below).
3. Layout (example)
src/pages/_layout.tsx:
API
<FileRouter>
Auto-generates routes from pages/ directory and manages coordinator/spoke pushing via TamerNav.
Cross-spoke state: providerConnector
Each spoke LynxView gets a fresh JS context. Module-level singletons (Zustand, Redux) re-evaluate per spoke. React Context set on the coordinator doesn't survive into spokes. If your app uses any React provider — state management, theming, i18n, data fetching — you need providerConnector to carry that state across screen pushes.
Pass providerConnector to bridge state explicitly:
On push, FileRouter serializes all syncs to JSON and hydrates spokes. On mutation, spokes re-serialize back via TamerNav.update. Coordinators listen to tamer-nav:dispatch events.
Built-in connectors
tamer-router ships purpose-built connectors for the libraries the example app exercises. Each one is a thin wrapper around createTamerStateSync that knows how to serialize / hydrate that library's store and (for Redux-style libs) how to forward dispatched actions across spokes.
Each connector picks a unique key — keep them stable across releases (the JSON aggregate is keyed by it).
Connector lifecycle
The coordinator owns the providerConnector array and runs the aggregate. Per push/pop the flow is:
- On
TamerNav.push— coordinator callsserialize()on each sync, builds{ [key]: <slice> }, and passes the aggregate asstateJson. - Spoke mounts —
FileRouter(mounted on the spoke side as well) readsreadHydratedStateJson()once, splits the aggregate by key, and calls each sync'shydrate(json). The store now matches the coordinator's snapshot. - On spoke mutation — when a tracked store updates, the spoke re-serializes and broadcasts via
TamerNav.update({ stateJson }). Coordinator receives it, re-hydrates its own copy, then all other open spokes also re-hydrate. Stores stay coherent across the stack. - For libraries with actions (Redux): the spoke's
store.dispatch(action)is also wrapped — the action is sent as atamer-nav:dispatchevent and replayed on the coordinator before the nextupdatefires.
If dispatchConnectorMutations is enabled, the same dispatch round-trip is applied to every connector, not just Redux.
Writing a connector for an unsupported provider
If your library isn't in the table above, you can still bridge it. Build a TamerStateSync directly:
Contract:
getState()— return the JSON-serializable snapshot. If your store holds non-serializable values (functions, class instances, dates), strip / convert them here.subscribe(listener)— calllistener()whenever state changes. Return an unsubscribe function.hydrate(json)— accept the JSON snapshot and replace local state. Idempotent —tamer-routermay call this multiple times during cross-spoke updates.send(action)— optional. Use only when there's a meaningful action object (Redux, Pinia mutations) you want replayed on every spoke. For pure state-replication libs (Zustand, Apollo cache), leave it off.
createTamerStateSync is intentionally tiny — every built-in connector is just a thin shim that knows the library's API for the four bullets above. If you author one for a popular library, please file a PR so it can be added to the built-in list.
Non-React Lynx bindings (miso-lynx, VueLynx, …)
tamer-router is React-only. For Vue or Haskell Lynx bindings, use tamer-navigation directly — that page lays out the equivalent state-hydration patterns for Vue's Pinia, Haskell's effect models, and other bindings.
<Stack>
Stack navigation with AppBar and Content.
<Tabs>
Tabs navigation with AppBar, Content, and TabBar.
useBackHandler / usePreventBack
Intercept hardware back (Android) / pop gesture (iOS) before default handling.
Handlers are LIFO. When none return true, FileRouter pops the stack. In manual coordinator setups, you handle the fallback yourself.
useTamerRouter()
Re-exports from React Router
useLocation, useNavigate, useParams, useLocalSearchParams, Outlet / Slot, Link
