Building a Multi-Screen App
Dim does not ship a router package. Production-style demos (Shopping, Messaging) compose useStore, a navigation stack, and transitionId into a multi-screen experience. This tutorial extracts that pattern so you can reuse it.
Reference implementations: Shopping demo and Messaging demo in Storybook.
Core state shape
Persist navigation and domain data with one useStore call:
const store = useStore({
activeTab: useState(0),
navStack: useState(["catalog"]), // or ["conversations"] in messaging
cart: useState([]),
theme: useState("light"),
});
const [activeTab, setActiveTab] = store.activeTab;
const [navStack, setNavStack] = store.navStack;
const [cart, setCart] = store.cart;
Using useStore means tab, stack, and cart survive reloads and sync across mounted components.
Navigation stack
Model screens as string tokens on a stack:
// Tab roots
"catalog" | "cart" | "orders" | "profile"
// Pushed screens
"product:42"
"checkout"
"confirmation:order-123"
Helpers:
const push = (screen) => setNavStack([...navStack, screen]);
const pop = () =>
navStack.length > 1 ? setNavStack(navStack.slice(0, -1)) : navStack;
const resetToTab = (root) => setNavStack([root]);
const top = navStack[navStack.length - 1];
const isPushed =
top.startsWith("product:") ||
top === "checkout" ||
top.startsWith("confirmation:");
Switching tabs resets the stack to that tab’s root; pushing keeps the tab bar visible with a back affordance.
Deriving transitionId
transitionId must change whenever the visible screen changes so slides run in the correct direction:
const TAB_VIEW_IDS = { 0: 10, 1: 11, 2: 12, 3: 13 };
const computeViewId = (activeTab, navStack) => {
const top = navStack[navStack.length - 1];
if (top.startsWith("confirmation:")) return "260";
if (top === "checkout") return "250";
if (top.startsWith("product:")) {
return String(200 + hashId(top.split(":")[1]));
}
return String(TAB_VIEW_IDS[activeTab] ?? 10);
};
const viewId = computeViewId(activeTab, navStack);
Rules of thumb:
- Monotonic ids for forward/back within a flow (checkout < confirmation)
- Tab ids in a separate numeric range from push ids
- Stable ids for the same screen (product id embedded in string)
Pass viewId to your root navigation component:
html`
<navigation-view
transitionId="${viewId}"
.props=${{ activeTab, navStack, cart, push, pop, setActiveTab }}
></navigation-view>
`;
See 6. View Transitions and 7. Shared-Element Transitions.
Component composition with useScope
Register child screen components once per app module:
import "./CatalogView.js";
import "./ProductDetailView.js";
import "./CartView.js";
const NavigationView = (props, { useScope, html }) => {
useScope({
"catalog-view": CatalogView,
"product-detail-view": ProductDetailView,
"cart-view": CartView,
});
const top = props.navStack[props.navStack.length - 1];
return html`
<div class="shell">
${renderScreen(top, props)}
</div>
`;
};
Each view is its own custom element with isolated styles via useStyle.
Tab bar + back button
const onTabClick = (tabId, root) => {
setActiveTab(tabId);
setNavStack([root]);
};
return html`
<nav class="tabs">
${TABS.map(
(tab) => html`
<button
class="${activeTab === tab.id ? "active" : ""}"
@click="${() => onTabClick(tab.id, tab.root)}"
>
${tab.icon} ${tab.label}
</button>
`
)}
</nav>
${isPushed
? html`<button class="back" @click="${pop}">← Back</button>`
: ""}
`;
Shared elements across screens
Product list → detail transitions morph image and title:
const keys = sharedKeys(product.id);
html`
<img data-vt-shared="${keys.image}" src="${product.image}" />
<span data-vt-shared="${keys.name}">${product.name}</span>
`;
Reuse the same keys in the detail view.
What to extract into a future router
The demos duplicate computeViewId, stack helpers, and tab sync. A future useRouter hook (see dim/todo.md) could provide:
push,pop,replace- Derived
transitionId - Tab ↔ stack coordination
Until then, copy the shopping or messaging demo structure as your template.
Conclusion
A Dim multi-screen app is: global store + string stack + computed transitionId + scoped views + optional shared-element keys. No separate SPA framework required.