Skip to content

Client-side router with support for a tree of route handlers 🐟

Notifications You must be signed in to change notification settings

kcrwfrd/trouter

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

79 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@kcrwfrd/trouter 🐟

A client-side hash fragment URL router with a tree of async resolvers and controllers, built for single-page applications.

Key Features

  • Framework-agnostic — bind plain functions or classes as route handlers
  • Hierarchical route tree with async data resolution — parent resolvers run before child controllers
  • Zero dependencies
  • Path params (:id), query params (?filter), and nested URL composition
  • Controller exit hooks — async, can block navigation
  • Transition lifecycle hooksonStart, onSuccess, onError

Install

npm install @kcrwfrd/trouter

Quick Start

import Router from '@kcrwfrd/trouter'

const router = new Router()

router
  .route('home', {
    url: '/',
    controller: (params) => {
      document.body.textContent = 'Home'
    }
  })
  .route('users', {
    url: '/users',
    resolve: () => fetch('/api/users').then(r => r.json()),
    controller: (params, users) => {
      document.body.textContent = `${users.length} users`
    }
  })

router.listen()

Navigate by updating the hash (#!/users) or programmatically:

router.go('users')

Core Concepts

Route Tree & Async Resolution

Routes form a tree. Each route can define a resolve function that fetches data before its controller runs. Parent resolvers execute before child controllers, so data flows down the tree.

router
  .route('users', {
    url: '/users/:userId',
    resolve: (params) => fetch(`/api/users/${params.userId}`).then(r => r.json()),
    controller: (params, user) => {
      // `user` is the resolved data from this route's resolve
    }
  })
  .route('users.posts', {
    url: '/posts',
    controller: (params) => {
      // By the time this runs, the parent `users` route has already
      // resolved and its controller has executed.
    }
  })
  .route('users.posts.create', {
    url: '/create',
    controller: (params) => {
      // Parent chain: users → users.posts → users.posts.create
      // Each resolve/controller pair runs in sequence down the tree.
    }
  })

Navigating to #!/users/123/posts/create will:

  1. Resolve users (fetch user 123)
  2. Enter users controller
  3. Enter users.posts controller
  4. Enter users.posts.create controller

Defining Routes

Register routes with router.route(name, definition). Routes return the router for chaining.

router
  .route('admin', {
    url: '/admin',
    abstract: true // cannot be navigated to directly; serves as a parent
  })
  .route('admin.users', {
    url: '/users',   // full URL becomes /admin/users
    controller: AdminUsersController
  })
  .route('admin.settings', {
    url: '/settings', // full URL becomes /admin/settings
    controller: AdminSettingsController
  })

Hierarchy is established in three ways:

  1. Dot notation'admin.users' automatically parents under 'admin'
  2. parent property{ parent: 'admin', ... }
  3. Route instance{ parent: adminRoute, ... }

Child URLs are appended to parent URLs. A parent with /users/:userId and a child with /posts/:postId produces /users/:userId/posts/:postId.

Controllers

Controllers are plain functions or classes. They receive (params, resolvedData) when the route is entered.

// Function controller
router.route('home', {
  url: '/',
  controller: (params, data) => {
    document.querySelector('#app').innerHTML = renderHome(data)
  }
})

// Class controller with onExit hook
class UserController {
  constructor(params, user) {
    this.render(user)
  }

  onExit() {
    // Called when leaving this route.
    // Return a promise to delay the transition (e.g. confirm unsaved changes).
    return cleanup()
  }
}

router.route('user', {
  url: '/users/:userId',
  resolve: (params) => fetchUser(params.userId),
  controller: UserController
})

A controller class can also define a static resolve:

class UserController {
  static resolve(params) {
    return fetchUser(params.userId)
  }

  constructor(params, user) {
    this.render(user)
  }
}

router.route('user', {
  url: '/users/:userId',
  controller: UserController
  // no separate `resolve` needed — uses UserController.resolve
})

Resolvers

The resolve property supports several formats:

// Function — receives params, returns a value or promise
resolve: (params) => fetch(`/api/items/${params.id}`)

// Object — named resolves run in parallel, controller receives the keyed results
resolve: {
  users: () => fetch('/api/users').then(r => r.json()),
  posts: () => fetch('/api/posts').then(r => r.json())
}
// controller receives { users: [...], posts: [...] }

// Array — parallel resolution, controller receives array
resolve: [
  () => fetch('/api/users').then(r => r.json()),
  () => fetch('/api/posts').then(r => r.json())
]
// controller receives [usersData, postsData]

// Promise
resolve: Promise.resolve({ cached: true })

If a resolver rejects, the transition is cancelled and the router state is restored.

URL Patterns

Path params are prefixed with : and are required:

/users/:userId          →  #!/users/42
/users/:userId/posts/:postId  →  #!/users/42/posts/7

Query params are listed after ? and are optional:

/search?query&page      →  #!/search?query=hello&page=2
                        →  #!/search?query=hello  (page omitted)
                        →  #!/search              (both omitted)

Query params that are null or undefined are omitted from the URL. Falsy values like 0, false, and '' are included.

API Reference

new Router({ prefix })

Create a router instance.

Option Type Default Description
prefix String '#!' URL prefix. Use '#!' for hash routing or '' for History API routing.

router.route(name, definition)Router

Register a route. Returns the router for chaining.

router.listen()

Start listening for URL changes. Immediately processes the current URL. Uses popstate if the History API is available, otherwise hashchange.

router.go(name, params)Promise

Navigate to a named route. Updates the browser URL and returns a promise that resolves with the new router.current state. Throws if the route name is not found.

router.href(name, params)String

Generate a URL string for a route. Inherits current params by default, merged with any provided params.

router.href('users.posts', { userId: 42, postId: 7 })
// → '#!/users/42/posts/7'

router.reload(params, hardRefresh)Promise

Reload the current route. If hardRefresh is true, performs a full browser reload. Otherwise re-runs the transition.

router.transitionTo(route, params, options)Promise

Low-level transition method. options.location controls whether the browser URL is updated.

router.pushState(state, title, url)

Wrapper around window.history.pushState with hash fallback.

Transition Hooks

router.transitions.onStart((route) => {
  // Called before each transition. Return a promise to delay it.
  showSpinner()
})

router.transitions.onSuccess((current) => {
  // Called after a successful transition.
  // current = { route, params }
  document.title = current.route.title
  hideSpinner()
})

router.transitions.onError((error) => {
  // Called when a transition fails (resolve rejected, onExit rejected, etc.)
  hideSpinner()
  showError(error)
})

Router State

Property Description
router.current Current state: { route, params }. Also exposes .url() and .path().
router.previous Previous state: { route, params }

Route Definition Properties

Property Type Description
name String Route identifier. Dot notation ('parent.child') establishes hierarchy.
url String URL pattern. Path params with :param, query params after ?.
controller Function|Class Called with (params, resolvedData) when route is entered.
resolve Function|Object|Array|Promise Data to resolve before the controller runs.
parent String|Route Parent route name or instance. Inferred from dot notation if omitted.
abstract Boolean If true, route cannot be navigated to directly (useful for layout routes).
title String Page title. Defaults to the route name.

Smart Transitions

When navigating between routes, Trouter calculates the minimal set of routes to exit and enter based on the nearest common ancestor.

Parent controllers are NOT re-invoked when navigating between siblings with unchanged parent params:

Navigate: users.detail → users.edit  (userId stays 42)
Exit:     users.detail
Enter:    users.edit
          (users controller is NOT re-entered)

Parent controllers ARE re-invoked when their params change:

Navigate: users.detail(userId=42) → users.detail(userId=99)
Exit:     users.detail, users
Enter:    users, users.detail
          (users controller IS re-entered because userId changed)

License

MIT

About

Client-side router with support for a tree of route handlers 🐟

Resources

Stars

Watchers

Forks

Packages

No packages published