Router, pages, layouts and async data in TiniJS apps

lamnhan

Nhan Lam

Posted on April 27, 2024

Router, pages, layouts and async data in TiniJS apps

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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``;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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``;
}
Enter fullscreen mode Exit fullscreen mode

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'),
  },
];
Enter fullscreen mode Exit fullscreen mode

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',
  },
];
Enter fullscreen mode Exit fullscreen mode

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',
      },
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

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: [
      // ...
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

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'},
];
Enter fullscreen mode Exit fullscreen mode

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'),
  },
];
Enter fullscreen mode Exit fullscreen mode

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>`;
  }

}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>`;
  }

}
Enter fullscreen mode Exit fullscreen mode

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};

}
Enter fullscreen mode Exit fullscreen mode

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
  }

}
Enter fullscreen mode Exit fullscreen mode

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>
    `;
  }

}
Enter fullscreen mode Exit fullscreen mode

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>`
    });
  }

}
Enter fullscreen mode Exit fullscreen mode

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 not undefined and not empty (empty is null 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>
      `,
    });
  }

}
Enter fullscreen mode Exit fullscreen mode

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,
  });

}
Enter fullscreen mode Exit fullscreen mode

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 ...',
    // ...
  };

}
Enter fullscreen mode Exit fullscreen mode

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);
  }

}
Enter fullscreen mode Exit fullscreen mode

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! 💖

💖 💪 🙅 🚩
lamnhan
Nhan Lam

Posted on April 27, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related