A client-side hash fragment URL router with a tree of async resolvers and controllers, built for single-page applications.
- 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 hooks —
onStart,onSuccess,onError
npm install @kcrwfrd/trouter
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')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:
- Resolve
users(fetch user 123) - Enter
userscontroller - Enter
users.postscontroller - Enter
users.posts.createcontroller
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:
- Dot notation —
'admin.users'automatically parents under'admin' parentproperty —{ parent: 'admin', ... }- 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 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
})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.
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.
Create a router instance.
| Option | Type | Default | Description |
|---|---|---|---|
prefix |
String |
'#!' |
URL prefix. Use '#!' for hash routing or '' for History API routing. |
Register a route. Returns the router for chaining.
Start listening for URL changes. Immediately processes the current URL. Uses popstate if the History API is available, otherwise hashchange.
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.
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'Reload the current route. If hardRefresh is true, performs a full browser reload. Otherwise re-runs the transition.
Low-level transition method. options.location controls whether the browser URL is updated.
Wrapper around window.history.pushState with hash fallback.
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)
})| Property | Description |
|---|---|
router.current |
Current state: { route, params }. Also exposes .url() and .path(). |
router.previous |
Previous state: { route, params } |
| 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. |
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)
MIT