Skip to main content

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.


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.