DEV Community

Luiz Américo
Luiz Américo

Posted on

slick-router: a powerful router for web components

TL;DR;
slick-router is a client side router library with a flexible API and advanced features like fine grained routing control, code lazy loading, nested state / rendering, page transition animations among others. Take a look at the source code and live demo of a complete app that uses it.

Routing is an important part of a client side web application and choosing the right strategy to handle it is one of the keys to create a solid project.

There are many ways (and libraries) to handle routing, from the minimal one where developer chooses how to respond to navigation to opinionated one where application state, including UI tree, is derived automatically from the routing state using some sort of configuration. The best way depends on complexity of application, framework (if any) used and developer preference.

Being framework agnostic and with its flexible API, slick-router allows to implement both a minimal or an opinionated routing strategy.

Core Concepts

The first step to use slick-router is to create the route definitions tree as show below

const routes = [
  {
    name: 'app',
    path: '/',
    children: [
      {
        name: 'about'
      },
      {
        name: 'post',
        path: ':postId',
        children: [
          {
            name: 'show'
          },
          {
            name: 'edit'
          }
        ]
      }
    ]
  }
]

Each route must have a name property whose value must be unique. If path is not defined, the value of name is used as the path. The children property receives an array of route definitions defining nested routes. The route tree can be defined also using a callback mechanism

Beside these basic options, arbitrary ones, e.g. 'handler', can be added to each route definition to be used by the middlewares.

Now create the a router instance:

import { Router } from 'slick-router'

const router = new Router({routes})

The next step is to register a custom or a predefined middleware, a function that receives a transition object each time a navigation occurs:

router.use(transition => {
  // do whatever you want here
  // transition holds the matched path, pathname, query, params, routes
})

Last, call listen:

router.listen()

A Minimal Approach

The example below calls the function associated to the handler option. More than one handler can be associated to each route and the function can be async.

const user = {
  list () {},
  async load () {},
  show () {},
  edit () {}  
}

const routes = [
  {
    name: 'users',
    path: '/',
    handler: user.list    
  },
  {
    name: 'user.show',
    path: '/user/:id/edit',
    handler: [user.load, user.show]
  },
  ,
  {
    name: 'user.edit',
    path: '/user/:id/edit',
    handler: [user.load, user.edit]     
  }
]

const router = new Router({routes})

function normalizeHandlers(handlers) {
  return Array.isArray(handlers) ? handlers : [handlers]
}

router.use(async function(transition) {
  for (const route of transition.routes) {
    const handlers = normalizeHandlers(route.options.handler)
    for (const handler of handlers) {
      await handler(transition)
    }
  }  
})

That's all. Basically mimics the interface of minimal routing libraries like page.js

On top of it if necessary is possible to add new features on a needed basis, like declarative redirect:

router.use(function(transition) {
  // only redirect with the exact matched route
  const leafRoute = transition.routes[transition.routes.length - 1]
  if (leafRoute.options.redirect) {
    transition.redirectTo(leafRoute.options.redirect)
  }
})

or declarative authorization handling:

router.use(function(transition) {
  // if any matched route is private, the routing is cancelled
  for (const route of transition.routes) {    
    if (route.options.private && !authService.isLogged) {
       transition.cancel()
       // or 
       transition.redirectTo('login')
    }
  }  
})

Using wc (Web Component) Middleware

While the minimal approach can work for many projects, it can become cumbersome for middle to large sized applications.

slick-router ships with wc middleware that renders the UI tree using web components and have advanced functionality like code lazy loading, lifecycle hooks and component reuse. It has an interface similar to vue-router

Setup

import { wc } from 'slick-router/middlewares/wc'

const routes = [];

const Router = new Router({routes, outlet: 'app-root'})

router.use(wc)

Besides registering the wc middleware, the code above pass an 'outlet' option to define where the components will be rendered. It can be a selector or an element instance. Defaults to document.body.

Component Declaration

import './components/home-view.js'
import LoginView from './components/login-view.js'

function AboutView () {
  return import('./components/about-view.js')
}

const routes = [
  {
    name: 'home',
    component: 'home-view'
  },
  {
    name: 'login',
    component: LoginView
  },
  {
    name: 'about',
    component: AboutView
  }
]

The above example shows how components can be declared: as a string pointing to a tag name or as an HTMLElement descendant constructor. Is possible also to define as a function returning a string or a constructor that will be lazy loaded. The function can be async providing a seamless way to do code splitting when, e.g., used with a bundler or with manual async code loading.

Lifecycle Hooks

Is possible to define lifecycle hooks in the route definition:

const routes = [
  {
    name: 'home',
    component: 'home-view'
  },
  {
    name: 'admin',
    component: 'admin-view',
    beforeEnter: (transition) => {
      if (!authService.isLogged) transition.redirectTo('home')
    }
  }
]

or in the component class:

class AdminView extends HTMLElement {
  beforeRouteEnter(transition) {
    if (!authService.isLogged) transition.redirectTo('home')
  }
}

See here the existing hooks.

Nested Rendering

When a route is nested, its component is rendered in an outlet element from the parent component. By default, the outlet is a 'router-outlet' element.

const routes = [
  {
    name: 'app',
    component: 'app-shell'
    children: [
      {
        name: 'dashboard',
        component: 'dashboard-view'   
      },
      {
        name: 'about',
        component: 'about-view'   
      }
    ]
  }
]

class AppShell extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <h1>My App</h1>
      <a href="#dashboard">Dashboard</a>
      <a href="#about">About</a>
      <router-outlet></router-outlet>
    `
  }
}

customElements.define('app-shell', AppShell)

In the above example, when navigating to 'dashboard' route, 'dashboard-view' component will be rendered inside 'router-outlet' element from 'app-shell'. Transitioning from 'dashboard' to 'about' route will remove 'dashboard-view' and render 'about-view'. In this case 'app-shell' component will not be re-rendered.

The outlet selector can be configured using static outlet field:

class AppShell extends HTMLElement {
  static get outlet() {
    return '.pages'
  }

  connectedCallback() {
    this.innerHTML = `
      <h1>My App</h1>
      <a href="#dashboard">Dashboard</a>
      <a href="#about">About</a>
      <div class="pages"></div>
    `
  }
}

Animating Element Transitions

Animating element transitions can be accomplished by using AnimatedOutlet component. It must be explicitly registered to a HTML tag, more commonly to 'router-outlet'.

import { AnimatedOutlet } from 'slick-router/components/animated-outlet.js'

customElements.define('router-outlet', AnimatedOutlet)

To enable the animation, add an 'animation' attribute:

class AppShell extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <h1>My App</h1>
      <a href="#dashboard">Dashboard</a>
      <a href="#about">About</a>
      <router-outlet animation></router-outlet>
    `
  }
}

By default, it uses the same pattern as vue transitions adding 'outlet-enter', 'outlet-enter-active', 'outlet-enter-to' classes to element being added and 'outlet-leave', 'outlet-leave-active', 'outlet-leave-to' classes to element being removed.

The class names can be configured by setting a value to animation attribute, so <router-outlet animation="user"></router-outlet> will use 'user-enter', 'user-enter-active' etc classes.

See live demo

Is also possible to change how animation is done by descending AnimationHook class. Out of box, AnimateCSS class, that allows to use animate.css is provided.

import { AnimatedOutlet, setDefaultAnimation, AnimateCSS } from 'slick-router/components/animated-outlet.js'

setDefaultAnimation(AnimateCSS)

customElements.define('router-outlet', AnimatedOutlet)

See in action here

Finally, is possible to use JavaScript animation libraries as demoed here or use AnimatedOutlet component (without a router)

Final Notes

  • Not all features are described here as automatic link handling, component reuse or more granular middleware API. Refer to slick-router docs for more info.
  • slick-router is not exactly new since is basically a modernized and improved version of CherryTree, a six year old project.
  • nextbone-routing is another routing library for web components, built on top of slick-router and a bit more opinionated. It puts routing in a central place in the application architecture, acting as a kind of orchestrator.

Top comments (0)