Router, pages, layouts and async data in TiniJS apps
Nhan Lam
Posted on April 27, 2024
Welcome again, friends! 🥳
In the previous topic, we explored about how to get started with the TiniJS Framework, if you have not read it yet, please see Getting started with TiniJS framework.
Today topic we will explore:
- The Tini Router and alternatives
- Working with Pages and layouts
- Employ route guards
- Scroll to anchors inside Shadow DOM
- Title and meta tags management
- Fetch and render async data
To get started, you can download the Blank starter template, or run:
npx @tinijs/cli@latest new my-app -t blank
Pages
Pages in TiniJS apps are special components which purpose are to represent views or endpoints of the app. Creating and working with pages is very similar to how we would work with components.
To quickly create a page, we can use the Tini CLI to generate it.
npx tini generate page xxx
Or, create a ./app/pages/xxx.ts
file manually, a page looks like this:
import {html, css} from 'lit';
import {Page, TiniComponent} from '@tinijs/core';
@Page({
name: 'app-page-xxx',
})
export class AppPageXXX extends TiniComponent {
protected render() {
return html`<p>This is a page!</p>`;
}
static styles = css``;
}
Beside the @Page()
decorator, everything else would work the same as any component. But, please note the name: 'app-page-xxx'
property, it plays a role later when we setup the Tini Router.
Layouts
Layouts in TiniJS apps are also special components which purpose are to share common elements between pages. You can think of layouts as containers of pages.
To quickly create a layout, we can use the Tini CLI to generate it.
npx tini generate layout xxx
Or, create a ./app/layouts/xxx.ts
file manually, a layout looks like this:
import {html, css} from 'lit';
import {Layout, TiniComponent} from '@tinijs/core';
@Layout({
name: 'app-layout-xxx',
})
export class AppLayoutXXX extends TiniComponent {
protected render() {
return html`
<div class="page">
<header>...</header>
<slot></slot>
<footer>...</footer>
</div>
`;
}
static styles = css``;
}
Beside the @Layout()
decorator and the <slot></slot>
in the template, everything else would work the same as any component. But, please note the name: 'app-layout-xxx'
property, it plays a role later when we setup the Tini Router.
Tini Router
Tini Router is the default way to add routing capability to TiniJS apps. There are also other routers you may use with TiniJS, such as: Vaadin Router and Lit Router.
For today topic, we will only explore the usage of Tini Router, it has several useful features:
- Bundle or lazy load pages
- Routes with layouts
- Many param patterns
- Navigate using the
a
tag - Route guards
- 404 pages
- And more
Define routes
To define routes, we create the file ./app/routes.ts
and add the route entries, for example:
import type {Route} from '@tinijs/router';
export const routes: Route[] = [
{
path: '',
component: 'app-layout-default',
children: [
{
path: '',
component: 'app-page-home',
action: () => import('./pages/home.js'),
},
{
path: 'post/:slug',
component: 'app-page-post',
action: () => import('./pages/post.js'),
},
// more app routes
],
},
{
path: 'admin',
component: 'app-layout-admin',
children: [
{
path: '',
component: 'app-page-admin-home',
action: () => import('./pages/admin-home.js'),
},
// more admin routes
],
},
{
path: '**',
component: 'app-page-404',
action: () => import('./pages/404.js'),
},
];
We can model our app routing system in several ways.
Without layout
Serve pages directly without a layout.
export const routes: Route[] = [
{
path: '',
component: 'app-page-home',
},
{
path: 'about',
component: 'app-page-about',
},
];
With layouts
Share similar elements between pages.
export const routes: Route[] = [
{
path: '',
component: 'app-layout-default',
children: [
{
path: '',
component: 'app-page-home',
},
{
path: 'about',
component: 'app-page-about',
},
],
},
];
Bundle or lazy load
You can either bundle or lazy load pages and layouts. Please note that for bundled layouts and pages, they must be imported first either in routes.ts
or app.ts
.
import './layouts/default.js';
import './pages/home.js';
export const routes: Route[] = [
// bundled layout
{
path: '',
component: 'app-layout-default',
children: [
// bundled page
{
path: '',
component: 'app-page-home',
},
// lazy-loaded page
{
path: 'about',
component: 'app-page-about',
action: () => import('./pages/about.js'),
},
],
},
// lazy-loaded layout
{
path: 'admin',
component: 'app-layout-admin',
action: () => import('./layouts/admin.js'),
children: [
// ...
],
},
];
Route parameters
Route parameters are defined using an express.js
-like syntax. The implementation is based on the path-to-regexp
library, which is commonly used in modern frontend libraries and frameworks.
The following features are supported:
Type | Syntax |
---|---|
Named parameters | profile/:user |
Optional parameters | :size/:color? |
Zero-or-more segments | kb/:path* |
One-or-more segments | kb/:path+ |
Custom parameter patterns | image-:size(\d+)px |
Unnamed parameters | (user[s]?)/:id |
// courtesy of: https://hilla.dev/docs/lit/guides/routing#parameters
export const routes: Route[] = [
{path: '', component: 'app-page-home'},
{path: 'profile/:user', component: 'app-page-profile'},
{path: 'image/:size/:color?', component: 'app-page-image'},
{path: 'kb/:path*', component: 'app-page-knowledge'},
{path: 'image-:size(\\d+)px', component: 'app-page-image'},
{path: '(user[s]?)/:id', component: 'app-page-profile'},
];
404 routes
You can define one or more 404 routes to catch not found routes, set path
to **
.
There are 2 level of 404: layout level and global level. The router will use the layout 404 first if no definition found at the layout level, it will then use the global 404.
export const routes: Route[] = [
{
path: '',
component: 'app-layout-default',
children: [
// layout routes
{
path: '**',
component: 'app-page-404-layout-default',
action: () => import('./pages/404-layout-default.js'),
},
],
},
// other routes
// global 404
{
path: '**',
component: 'app-page-404-global',
action: () => import('./pages/404-global.js'),
},
];
Init the Router
After defining routes for the app, next step would be init a router instance and register the routes.
From the app.ts
we import the defined routes, create a router instance and add the router outlet to the template.
import {createRouter} from '@tinijs/router';
import {routes} from './routes.js';
@App({})
export class AppRoot extends TiniComponent {
readonly router = createRouter(routes, {linkTrigger: true});
protected render() {
return html`<router-outlet .router=${this.router}></router-outlet>`;
}
}
Navigate between pages
With the option linkTrigger: true
enabled, you can navigate between pages using the a
just like normal links.
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/post/post-1">Post 1</a>
You can also use the <tini-link>
component provided by the Tini UI when link trigger disabled (not set or linkTrigger: false
), it has a similar signature compared to the a
tag and other useful stuffs, such as marked as active link.
<tini-link href="/">Home</tini-link>
<tini-link href="/about" active="activated">About</tini-link>
<tini-link href="/post/post-1" active="activated">Post 1</tini-link>
Access router and params
You can also navigate between pages in the imperative manner by using the go()
method from a router instance.
import {getRouter, UseRouter, type Router} from '@tinijs/router';
@Page({})
export class AppPageXXX extends TiniComponent {
// via decorator
@UseRouter() readonly router!: Router;
// or, via util
readonly router = getRouter();
protected render() {
return html`<button @click=${() => this.router.go('/')}>Go home</button>`;
}
}
Access current route and params is similar to access router instance.
import {UseRoute, UseParams, type ActivatedRoute} from '@tinijs/router';
@Page({})
export class AppPageXXX extends TiniComponent {
// current route
@UseRoute() readonly route!: ActivatedRoute;
// route params
@UseParams() readonly params!: {slug: string};
}
Route hooks and guards
Lifecycle hooks are used to perform actions when a route is activated or deactivated:
-
onBeforeEnter()
: called when the route is about to be activated -
onAfterEnter()
: called when the route is activated -
onBeforeLeave()
: called when the route is about to be deactivated -
onAfterLeave()
: called when the route is deactivated
You can intercept the navigation process by returning a string
or a function
from the onBeforeEnter
and onBeforeLeave
hook:
-
nullish
: continue the navigation process -
string
: cancel and redirect to the path -
function
: cancel and execute the function
@Page({})
export class AppPageAccount extends TiniComponent {
onBeforeEnter() {
if (user) return; // continue
return '/login'; // redirect to login page
}
}
You can also perform async actions inside hooks, then it will wait for the actions to be resolved before process further.
Scroll to anchors
Because we use the Shadow DOM to encapsulate our app, the browser seems to be unable to serve us the correct section when we present a link with an anchor fragment /post/post-1#section-a
.
Tini Router provides some methods to direct visitors to the respected sections and retrieve section headings for outlined purpose (aka. table of content).
import {ref, createRef, type Ref} from 'lit/directives/ref.js';
@Page({})
export class AppPageXXX extends TiniComponent {
@UseRouter() readonly router!: Router;
private _articleRef: Ref<HTMLElement> = createRef();
onRenders() {
// will scroll to the #whatever section if presented
this.router.renewFragments(
this._articleRef.value!,
{ delay: 500 }
);
// will scroll to the #whatever section if presented
// add extract all the available headings
// IMPORTANT!!!:
// + please don't: never change a local state in onRenders() or updated() or it will cause a render loop
// + please do: store 'fragments' in a global state or emit out to the parent component or employ render checkers
const fragments = this.router
.renewFragments(this._articleRef.value!, {delay: 500})
.retrieveFragments();
}
protected render() {
return html`
<article ${ref(this._articleRef)}>
...
</article>
`;
}
}
Async data
Pages are usually rendered based on some async data from server. You can use these techniques to work with such cases.
Task render
The @lit/task
package provides a Task
reactive controller to help manage this async data workflow.
import {Task} from '@lit/task';
@Page({})
export class AppPageXXX extends TiniComponent {
@Reactive() productId?: string;
private _productTask = new Task(this, {
task: async ([productId], {signal}) => {
const response = await fetch(`http://example.com/product/${productId}`, {signal});
if (!response.ok) { throw new Error(response.status); }
return response.json() as Product;
},
args: () => [this.productId]
});
protected render() {
return this._productTask.render({
pending: () => html`<p>Loading product...</p>`,
complete: (product) => html`
<h1>${product.name}</h1>
<p>${product.price}</p>
`,
error: (e) => html`<p>Error: ${e}</p>`
});
}
}
For more detail, please see https://lit.dev/docs/data/task/
Section render
Similar to Task Render, Section Render renders a section of a page based on the values of local states. There are 4 render states:
-
main
(complete): all are notundefined
and not empty (empty isnull
or[]
or{}
or zero-size Map) -
error
: any instanceof Error -
empty
: all are empty -
loading
: else
import {sectionRender, type SectionRenderData} from '@tinijs/core';
@Page({})
export class AppPageXXX extends TiniComponent {
@Reactive() product: SectionRenderData<Product>;
async onInit() {
this.product = await fetchProduct();
}
protected render() {
return sectionRender([this.product], {
loading: () => html`<p>Loading product ...</p>`,
empty: () => html`<p>No product found!</p>`,
error: () => html`<p>Errors!</p>`
main: ([product]) => html`
<h1>${product.name}</h1>
<p>${product.price}</p>
`,
});
}
}
Title and meta tags
To update page title and meta when navigating to different pages, init a meta instance at app.ts
.
import {initMeta} from '@tinijs/meta';
@App({})
export class AppRoot extends TiniComponent {
readonly meta = initMeta({
metadata: undefined, // "undefined" means use the extracted values from index.html
autoPageMetadata: true,
});
}
Static pages
When autoPageMetadata: true
for page which is static, meta can be provide via the metadata
property.
import type {PageMetadata} from '@tinijs/meta';
@Page({})
export class AppPageXXX extends TiniComponent {
readonly metadata: PageMetadata = {
title: 'Some title',
description: 'Some description ...',
// ...
};
}
Dynamic pages
For pages with data comes from the server, we can access the meta instance and set page metadata accordingly.
import {UseMeta, Meta} from '@tinijs/meta';
@Page({})
export class AppPageXXX extends TiniComponent {
@UseMeta() readonly meta!: Meta;
async onInit() {
this.post = await fetchPost();
this.meta.setPageMetadata(post);
}
}
Next topic will be: Adding features to TiniJS apps.
Thank you for spending time with me. If there was anything not working for you, please leave a comment or open an issue or ask for help on Discord, I'm happy to assist.
Wish you all the best and happy coding! 💖
Posted on April 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.