Chapter 02 — Views and Routes
In this chapter you will add two new pages to Deskflow: a public tasks list and a protected settings page. You will learn the shape of an HTML view, how the router table works, and how the make:view generator wires everything together so you do not have to do it manually.
Mental model
Think of a view as a dumb HTML fragment. It holds markup and ref attributes but has no logic. The router fetches it, injects it into #main-content, and then calls a controller factory to boot the logic for that page.
A route is one line in registerRoutes(r) that connects three things:
path → view HTML file → controller factory (lazy-loaded)
The router only loads the controller module when the user first visits that path. That is what createLazyController does: it returns a wrapper that imports the module on demand and calls your factory.
What a view file looks like
Views live under src/views/public/ or src/views/protected/. They contain markup only — no <script>, no <style>, no template literals.
A minimal tasks view:
<div class="tasks-page" data-view="tasks">
<header class="tasks-header">
<h1>Tasks</h1>
<span ref="countEl" class="tasks-badge">0</span>
</header>
<ul ref="listEl" class="tasks-list"></ul>
</div>
Three things to notice:
data-view="tasks"—CoreControlleruses this to find its root element.ref="countEl"— after the controller boots,this.countElis automatically- No
<script>or<style>tags — those belong in controllers andsrc/styles/.
Without it, the controller constructor will throw.
set to that <span> element. No querySelector needed.
How routes work
Open src/routes/routes.js. The scaffold looks like this:
import { createLazyController } from '@core/lazyController.js';
const lazyController = createLazyController(import.meta.url);
export function registerRoutes(r) {
// @group:public
r.group({}, (r) => {
r.register('/', 'src/views/public/home.html',
lazyController('homeController', '../controllers/home.controller.js'))
.cache({ ttl: 300, revalidate: true });
});
// @group:protected
r.group({ middleware: [] }, (r) => {
// make:view (protected) inserts here
});
}
Rules you must follow every time you add a route:
- Use
r.register— neverrouter.registerinsideregisterRoutes. - Never
importa controller at the top of the routes file. UselazyController. - The
lazyControllersecond argument is a relative path fromroutes.js - Protected routes go inside the
r.group({ middleware: [] })block. You will
to the controller file — typically '../controllers/foo.controller.js'.
add real middleware tags in a later chapter.
r.register signature
r.register(path, htmlFile, controller?, options?)
register returns this (the same router/r), so you can chain .cache(...) on the route you just registered.
Parameter 1 — path (string)
The URL pattern the browser shows. Leading slash required.
| Pattern | Matches | Params |
|---|---|---|
/ | Home only | {} |
/tasks | Exact /tasks | {} |
/tasks/:id | /tasks/42, /tasks/abc | { id: '42' } |
/tasks/:id? | /tasks or /tasks/42 | { id?: string } |
/files/* | /files/a/b/c | { wildcard: 'a/b/c' } |
Exact paths win over dynamic ones. Register /tasks/new before /tasks/:id if both exist. Deep dive on params and loaders: Chapter 16.
Parameter 2 — htmlFile (string)
Project-relative path to the view HTML the router fetches and injects into #main-content.
'src/views/public/tasks.html'
'src/views/protected/settings.html'
Use the real file path from the project root (same style the generators emit). Do not put a leading / on the file path.
Parameter 3 — controller (ControllerFunction | null)
Optional. Defaults to null (view-only route — HTML only, no page logic).
Recommended form — lazy factory from createLazyController:
lazyController('tasksController', '../controllers/tasks.controller.js')
| Piece | Meaning |
|---|---|
'tasksController' | Export name in the controller module (export function tasksController…) |
'../controllers/….js' | Path relative to routes.js, always with .js |
The factory the router calls has this shape:
(params, state?, loaderData?) => (() => void) | void | Promise<…>
| Argument | What it is |
|---|---|
params | Path params from the match ({ id: '42' }) |
state | History state from navigate(path, state) / popstate |
loaderData | Result of options.loader if you registered one; otherwise undefined |
Return a cleanup function — almost always () => ctrl.destroy(). Generated stubs may also declare a 4th rootElement argument; the router does not pass it. CoreController finds [data-view] (or #main-content for class controllers).
You may pass null or omit the third argument for a static HTML page with no controller.
Parameter 4 — options (Partial<RouteConfig>)
Optional object merged into the route config. Keys you can set:
| Option | Type | Purpose |
|---|---|---|
loader | (params, signal) => Promise<unknown> | Runs before the controller; result becomes loaderData. Use signal to abort on navigation cancel. |
layout | string | Path of another registered route to use as a layout shell (advanced). |
disableTransition | boolean | Skip page enter/exit transition for this route. |
cachePolicy | { ttl, revalidate? } | Same shape as .cache() — prefer chaining .cache() for readability. |
controller | — | Prefer the 3rd argument; do not duplicate here. |
htmlFile | — | Prefer the 2nd argument. |
Example with a loader:
r.register(
'/tasks/:id',
'src/views/public/task-detail.html',
lazyController('taskDetailController', '../controllers/task-detail.controller.js'),
{
loader: async (params, signal) => {
const res = await fetch(`/api/tasks/${params.id}`, { signal });
if (!res.ok) throw new Error('Task not found');
return res.json();
},
}
);
Chaining after register
r.register('/tasks', 'src/views/public/tasks.html',
lazyController('tasksController', '../controllers/tasks.controller.js'))
.cache({ ttl: 60, revalidate: true });
| Chain | Meaning |
|---|---|
.cache({ ttl }) | Cache view HTML for ttl seconds. On stale: block until refetch. |
.cache({ ttl, revalidate: true }) | Serve stale HTML immediately; refresh in the background. |
Default TTL when no policy is set is 300 seconds (router default). Prefetch and busting are covered in Chapter 16 (router.prefetch, bustCache).
r.group (wraps many register calls)
r.group({ middleware?: string[]; prefix?: string }, (r) => {
r.register(…);
});
| Option | Effect |
|---|---|
middleware: ['auth'] | Every route registered inside inherits those middleware tags (resolved in app.js). |
prefix: '/app' | Prepended to each path (register('/tasks', …) → /app/tasks). |
Groups nest. The scaffold’s protected block starts as middleware: [] until you add real tags in Chapter 09.
Generator: make:view
Rather than creating files by hand, use the generator. It creates the HTML view, an optional controller, updates routes.js, and updates any view maps — all in one command.
Interactive (prompts you for options):
npm.cmd run make:view -- tasks
Non-interactive (CI-friendly, or when you already know what you want):
# Public route at /tasks with a controller
npm.cmd run make:view -- tasks --defaults
# Protected route at /settings with a controller
npm.cmd run make:view -- settings --protected --defaults
# Dynamic route (no controller)
npm.cmd run make:view -- task-detail --route /tasks/:id --no-controller --defaults
make:page is an alias for make:view. Both do the same thing.
After each generator run, open src/routes/routes.js and confirm the new r.register line was inserted in the right group.
Lab: add the Deskflow pages
Make sure your dev server is running (npm run dev), then run:
npm.cmd run make:view -- tasks --defaults
npm.cmd run make:view -- settings --protected --defaults
You will see generator output listing the files created. Now open each one:
src/views/public/tasks.html — Replace the generated placeholder content with the tasks markup from above (keep data-view="tasks", add ref="countEl" and ref="listEl"):
<div class="tasks-page" data-view="tasks">
<header class="tasks-header">
<h1>My Tasks</h1>
<span ref="countEl" class="tasks-badge">0</span>
</header>
<ul ref="listEl" class="tasks-list"></ul>
</div>
src/views/protected/settings.html — Replace with a simple shell:
<div class="settings-page" data-view="settings">
<h1>Settings</h1>
<p>Preferences will go here.</p>
</div>
src/controllers/tasks.controller.js — The generator created a stub. Open it and make sure the factory calls ctrl.destroy() on cleanup:
import { CoreController } from '@core/controller.js';
export class TasksController extends CoreController {
onMount() {
this.assertRefs('countEl', 'listEl');
}
}
export function tasksController(_params, _state, _loaderData, rootElement) {
const ctrl = new TasksController(rootElement);
return () => ctrl.destroy();
}
src/routes/routes.js — Confirm it now contains both new routes:
r.register('/tasks', 'src/views/public/tasks.html',
lazyController('tasksController', '../controllers/tasks.controller.js'));
r.register('/settings', 'src/views/protected/settings.html',
lazyController('settingsController', '../controllers/settings.controller.js'));
Visit http://localhost:3000/tasks — you should see "My Tasks". Visit http://localhost:3000/settings — you should see "Settings". The settings route is not guarded yet (the middleware array is empty), so it renders for everyone. You will lock it down in a later chapter.
How the router injects views
When you navigate to /tasks:
- The router matches the path and fetches
src/views/public/tasks.html. - It parses the HTML and injects it into
<div id="main-content">inindex.html. - It calls the
tasksControllerfactory (lazy-importing the module first if needed). - When you navigate away, the factory's cleanup function (
() => ctrl.destroy())
runs to tear down listeners and reactive effects.
That cleanup step is the reason every factory must return () => ctrl.destroy(). Forgetting it means listeners pile up across navigations.
Apply to Deskflow
At the end of this chapter, Deskflow has:
src/views/public/home.html— starter home (scaffold default)src/views/public/tasks.html— your new tasks shellsrc/views/protected/settings.html— your new settings shell- Controllers for all three
- Three entries in
routes.js
That is a three-page SPA. No framework config, no bundler plugins — just files the router knows about.
Verify
- [ ]
/tasksrenders "My Tasks" without console errors - [ ]
/settingsrenders "Settings" without console errors - [ ] Both route registrations use
r.register(notrouter.register) - [ ]
tasks.htmlhasdata-view="tasks",ref="countEl",ref="listEl" - [ ]
settings.htmllives undersrc/views/protected/ - [ ] Both controller factories return
() => ctrl.destroy()
Common mistakes
| Mistake | Fix |
|---|---|
router.register(...) inside registerRoutes | Use r.register — the parameter |
Top-level import HomeController from '../controllers/home.controller.js' in routes | Only use lazyController for route controllers |
<script> tag inside a view HTML file | Move logic to the controller |
Missing data-view attribute | CoreController constructor will throw — add it |
Forgetting () => ctrl.destroy() in the factory | Listeners leak across navigations |
| Missing 3rd/4th argument confusion | Order is path, htmlFile, controller, options — loaders go in options, not as a 3rd arg |
Absolute file path or leading / on htmlFile | Use src/views/… relative to the project root |
Export name mismatch in lazyController('foo', …) | Must match export function foo / export class Foo exactly |
Challenges
Bronze: Add a third public route /about with no controller. Use --no-controller --defaults. Confirm it renders and that no controller error appears in the console.
Silver: Open src/routes/routes.js and add .cache({ ttl: 60 }) to the /tasks registration. Visit /tasks, navigate away to /, then come back. Open the Network tab in DevTools. Does the tasks HTML file re-fetch? Why not?
Gold: Read .nativecore/core/router.ts and find the prefetch method. Add a .prefetch() call to the /tasks route registration. Then open the Network tab and hard-refresh. Do you see the tasks HTML requested before you navigate to /tasks? Document what you observe.