Router, pages, layouts and async data in TiniJS apps

RMAG news

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 -l

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() 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!!!:
// + never change a local state in onRenders() or updated() or it will cause a render loop
// + 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:

loading: all dependencies are undefined

empty: all are null or [] or {} or zero-size Map

error: any instanceof Error

main: fulfilled scenario

import {
render as sectionRender, // renamed to sectionRender in v0.18.0
type RenderData as SectionRenderData // renamed to SectionRenderData in v0.18.0
} 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: Bringing functionalities to TiniJS apps.

Thank you for spending time with me. If there is anything not working for you, please leave a comment or contact me on Discord, I’m happy to assist.

Wish you all the best and happy coding! 💖

Leave a Reply

Your email address will not be published. Required fields are marked *