Routing
Learn how to make your application respond to changes in the URL and work with the browser’s back and forward buttons.
Overview
NOTE This guide uses hash-based routing instead of pushstate because hash-based routing is easier to setup. Pushstate routing requires server-support. Use can-route-pushstate for pushstate-based applications. The use of can-route-pushstate is almost identical to can-route.
can-route is used to setup a bi-directional relationship with an observable and the browser’s location (the URL).
When the observable changes, the URL will be updated. When the URL changes the observable will be updated.
The following example uses can-route to cross-bind the URL to an observable’s state. To see the cross-binding in action, try:
Changing the URL’s hash to
#!&page=products
. Notice the observable state updates.Change the observable’s state to:
{ "page": "products", "id": "foosball" }
Notice the URL updates.
Click the back button (
⇦
). Notice the observable state updates.
See the Pen CanJS 6 - routing two-way binding by Bitovi (@bitovi) on CodePen.
The binding between the URL and the observable is set by setting route.data and calling route.start() as follows:
<mock-url></mock-url>
<p>observable’s state:</p>
<bit-json-editor></bit-json-editor>
<script src="//unpkg.com/mock-url@^6.0.0" type="module"></script>
<script src="//unpkg.com/bit-json-editor@^6.0.0" type="module"></script>
<script type="module">
import { ObservableObject, route } from "//unpkg.com/can@6/core.mjs";
var observable = new ObservableObject();
route.data = observable;
route.start();
// Set up the json editor to edit the observable.
document.querySelector("bit-json-editor").data = observable;
</script>
<style>
bit-json-editor {
height: 200px;
}
</style>
Often, the observable is an instance of a custom type. For example, you can connect the myCounter
observable from
the Technology Overview’s Key-Value Observables section to window.location
with:
<mock-url></mock-url>
<script type="module">
// Imports the <mock-url> element that provides
// a fake back, forward, and URL controls.
import "//unpkg.com/mock-url@^6.0.0";
import { route, ObservableObject } from "can";
class Counter extends ObservableObject {
static props = {
count: 0
};
increment() {
this.count += 1;
}
}
window.myCounter = new Counter();
route.data = myCounter;
route.start();
</script>
This will add #!&count=0
to the location hash.
myCounter.increment()
window.location.hash //-> "#!&count=1"
history.back()
myCounter.count //-> 0
window.location.hash //-> "#!&count=0"
Now, if you called myCounter.increment()
in the console, the window.location
will
change to #!count=1
. If you hit the back-button, myCounter.count
would be
back to 0
:
By default, can-route
serializes the observable’s data with can-param,
so that the following observable data produces the following URL hashes:
{ foo: "bar" } //-> "#!&foo=bar"
{ foo: [ "bar", "baz" ] } //-> "#!&foo[]=bar&foo[]=baz"
{ foo: { bar: "baz" } } //-> "#!&foo[bar]=baz"
{ foo: "bar & baz" } //-> "#!&foo=bar+%26+baz"
NOTE can-route uses hash-bangs (
#!
) to comply with a now-deprecated Google SEO recommendation.
You can register routes that control the relationship between the observable and the browser’s location. The following registers a translation between URLs and route properties:
route.register("{count}")
This results in the following translation between observable data and URL hashes:
{ count: 0 } //-> "#!0"
{ count: 1 } //-> "#!1"
{ count: 1, type: "counter" } //-> "#!1&type=counter"
You can add data when the URL is matched. The following registers data for when the URL is matched:
route.register("products", { page: "products" });
route.register("products/{id}", { page: "products" })
This results in the following translation between observable data and URL hashes:
{ page: "products" } //-> "#!products"
{ page: "products", id: 4 } //-> "#!products/4"
Registering the empty route (""
) provides initial state for the
application. The following makes sure the count starts at 0 when the hash is empty:
route.register("", { count: 0 });
Routing and the root component
Understanding how to use can-route within an application comprised of can-stache-elements and their can-stache views and observable properties can be tricky.
We’ll use the following example to help make sense of it:
This example shows the <page-login>
component until someone has logged in. Once they have
done that, it shows a particular component based upon the hash. If the hash is empty (""
or "#!"
),
the <page-home>
component is shown. If the hash is like tasks/{taskId}
it will show the <task-editor>
component we created previously. (NOTE: We will show how to persist changes
to tasks in a upcoming service layer section.)
Switching between different components is managed by a <my-app>
component. The topology of
the application looks like:
In most applications, can-route is connected to a property on the top-level component. We are
going to go through the process of building <my-app>
and connecting it to can-route. This is
usually done in five steps:
- Define the top-level component's props.
- Create an observable key-value object on the component to represent the state of can-route.
- Connect this observable to the routing data.
- Have the top-level component’s view display the current sub-components based on its state.
- Register routes that translate between the URL and the application state.
Connect a component to can-route
To connect a component to can-route, we first need to create a basic component. The
following creates a <my-app>
component that includes links that will change the
route’s page property:
import { StacheElement, stacheRouteHelpers } from "can";
class MyApp extends StacheElement {
static view = `
The current page is .
<a href="{{ routeUrl(page='home') }}">Home</a>
<a href="{{ routeUrl(page='tasks') }}">Tasks</a>
`;
static props = {};
}
customElements.define("my-app", MyApp);
NOTE: Your html needs a
<my-app></my-app>
element to be able to see the component’s content. It should say "The current page is .".
To connect the component to the url, we:
- add a property to the component
props
object to hold the route.data key-value observable. - call route.start to bind the observable key-value object to the URL.
We also display the routeData.page
property.
import { route, StacheElement, stacheRouteHelpers } from "can";
class MyApp extends StacheElement {
static view = `
The current page is {{ this.routeData.page }}.
<a href="{{ routeUrl(page='home') }}">Home</a>
<a href="{{ routeUrl(page='tasks') }}">Tasks</a>
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
}
};
}
customElements.define("my-app", MyApp);
At this point, changes in the URL will cause changes in the routeData.page
property. See this by clicking the links and the back/refresh buttons below:
Display the right sub-components
Programmatically instatiated components can be used to create an instance of the
component that should be displayed for each route. We’ll use an {{expression}}
to display a componentToShow
property that we will implement in the component props:
import { route, StacheElement, stacheRouteHelpers } from "can";
class MyApp extends StacheElement {
static view: `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
}
};
}
customElements.define("my-app", MyApp);
The componentToShow
getter will return an instance of the component that should be shown.
The first step toward making this possible is to import the constructors for each can-stache-element:
import { route, StacheElement, stacheRouteHelpers } from "can";
import { PageHome, PageLogin, TaskEditor } from "can/demos/technology-overview/route-mini-app-components";
class MyApp extends StacheElement {
static view: `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
}
};
}
customElements.define("my-app", MyApp);
Once the component constructors are imported, they can be used to create an instance of the correct component in the componentToShow
getter:
import { route, StacheElement, stacheRouteHelpers } from "can";
import { PageHome, PageLogin, TaskEditor } from "can/demos/technology-overview/route-mini-app-components";
class MyApp extends StacheElement {
static view = `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return new PageLogin();
}
switch(this.page) {
case "home":
return new PageHome();
case "tasks":
return new TaskEditor();
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
}
};
}
customElements.define("my-app", MyApp);
Pass data to sub-components
Now the correct components will be displayed; however, the application will not be fully functional yet because these components do not have the state values they need in order to function correctly. can-value can be used to set up one-way and two-way bindings between the root component and each sub-component.
The Login page needs a property isLoggedIn
that represents whether the user is logged in. Since the login page handles logging in, it will need to be able to update this value, so we use value.bind to two-way bind this property.
To hook this up, we implement the isLoggedIn
property on the my-app
component and pass it to the
Login
page through can-stache-element
bindings:
import { route, StacheElement, stacheRouteHelpers, value } from "can";
import { PageHome, PageLogin, TaskEditor } from "can/demos/technology-overview/route-mini-app-components";
import "can/demos/technology-overview/mock-url";
class MyApp extends StacheElement {
static view = `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return new PageLogin().bindings({
isLoggedIn: value.bind(this, "isLoggedIn")
});
}
switch (this.routeData.page) {
case "home":
return new PageHome();
case "tasks":
return new TaskEditor();
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
},
isLoggedIn: false
};
}
customElements.define("my-app", MyApp);
The TaskEditor
page also needs to know the id of the task that is being edited. This property can be bound directly to the routeData
object:
import { route, StacheElement, stacheRouteHelpers, value } from "can";
import { PageHome, PageLogin, TaskEditor } from "can/demos/technology-overview/route-mini-app-components";
import "can/demos/technology-overview/mock-url";
class MyApp extends StacheElement {
static view = `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return new PageLogin().bindings({
isLoggedIn: value.bind(this, "isLoggedIn")
});
}
switch (this.routeData.page) {
case "home":
return new PageHome();
case "tasks":
return new TaskEditor().bindings({
id: value.bind(this.routeData, "taskId")
});
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
},
isLoggedIn: false
};
}
customElements.define("my-app", MyApp);
Lastly, a logout
function needs to be passed to the PageHome
and TaskEditor
components. Since this is a function and is not observable, it can be passed directly to these components without using can-value.
Note: make sure to use Function.prototype.bind() so that the
this
will correctly be the element, even when called from a child component.
import { route, StacheElement, stacheRouteHelpers, value } from "can";
import { PageHome, PageLogin, TaskEditor } from "can/demos/technology-overview/route-mini-app-components";
import "can/demos/technology-overview/mock-url";
class MyApp extends StacheElement {
static view = `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return new PageLogin().bindings({
isLoggedIn: value.bind(this, "isLoggedIn")
});
}
switch (this.routeData.page) {
case "home":
return new PageHome().bindings({
logout: this.logout.bind(this)
});
case "tasks":
return new TaskEditor().bindings({
id: value.bind(this.routeData, "taskId"),
logout: this.logout.bind(this)
});
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
},
isLoggedIn: false
};
logout() {
this.isLoggedIn = false;
}
}
customElements.define("my-app", MyApp);
Register routes
Currently, after the user logs in, the application will show <h2>Page Missing</h2>
because if the URL hash is empty, page
property will be undefined. To have page
be "home"
, one would have to navigate to "#!&page=home"
… yuck!
We want the page
property to be "home"
when the hash is empty. Furthermore,
we want URLs like #!tasks
to set the page
property. We can do that
by registering the following route:
route.register("{page}", { page: "home" });
Finally, we want #!tasks/5
to set page
to "tasks"
and taskId
to "5"
. Registering the following route does that:
route.register("tasks/{taskId}", { page: "tasks" });
Register these routes just before calling route.start
:
import { route, StacheElement, stacheRouteHelpers, value } from "can";
import { PageHome, PageLogin, TaskEditor } from "can/demos/technology-overview/route-mini-app-components";
import "can/demos/technology-overview/mock-url";
class MyApp extends StacheElement {
static view = `
{{ this.componentToShow }}
`;
static props = {
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("tasks/{taskId}", { page: "tasks" });
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return new PageLogin().bindings({
isLoggedIn: value.bind(this, "isLoggedIn")
});
}
switch (this.routeData.page) {
case "home":
return new PageHome();
case "tasks":
return new TaskEditor().bindings({
id: value.bind(this.routeData, "taskId"),
logout: this.logout.bind(this)
});
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
},
isLoggedIn: false
};
logout() {
this.isLoggedIn = false;
}
}
customElements.define("my-app", MyApp);
Now the mini application is able to translate changes in the URL to
properties on the routeData
property of the component. When the component’s
property changes, the view updates the page.
Progressively load the sub-components
Progressive loading is a technique that allows the application to only load the code for the each route when the route is displayed. This prevents loading code for pages the user may never visit.
When using progressive loading, the code for each route will be imported using a dynamic import instead of the static import { Page... } from "...components"
syntax.
Note: dynamic imports may not be natively supported in every browser, but similar functionality is available in StealJS and webpack.
Dynamic imports return a promise that will resolve once the code is loaded, so the componentToShow
property will become a promise. The can-reflect-promise package makes it easy to use promises directly in can-stache. The view can be updated to display the value
of the promise once it is resolved:
import { route, StacheElement, stacheRouteHelpers, value } from "can";
import "can/demos/technology-overview/mock-url";
class MyApp extends StacheElement {
static view = `
{{# if(this.componentToShow.isResolved) }}
{{ this.componentToShow.value }}
{{/ if }}
`;
static props = {
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("tasks/{taskId}", { page: "tasks" });
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return new PageLogin().bindings({
isLoggedIn: value.bind(this, "isLoggedIn")
});
}
switch (this.routeData.page) {
case "home":
return new PageHome().bindings({
logout: this.logout.bind(this)
});
case "tasks":
return new TaskEditor().bindings({
id: value.bind(this.routeData, "taskId"),
logout: this.logout.bind(this)
});
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
},
isLoggedIn: false
};
logout() {
this.isLoggedIn = false;
}
}
customElements.define("my-app", MyApp);
Note, can-reflect-promise also adds
isPending
andisRejected
properties to promises so that the view can handle these states as well.
Then update the componentToShow
getter to import the correct module. The value passed to the promise’s then method will be a module object with a property for each of the module’s exports. In this example, the component constructor is the default export, so an instance of the component can be created using new module.default({ /* ... */ })
. Returning the instances from the then
method will set componentToShow.value
to the component instance:
import { route, StacheElement, stacheRouteHelpers, value } from "can";
import "can/demos/technology-overview/mock-url";
class MyApp extends StacheElement {
static view = `
{{# if(this.componentToShow.isResolved) }}
{{ this.componentToShow.value }}
{{/ if }}
`;
static props = {
routeData: {
get default() {
route.register("{page}", { page: "home" });
route.register("tasks/{taskId}", { page: "tasks" });
route.start();
return route.data;
}
},
get componentToShow() {
if (!this.isLoggedIn) {
return import("can/demos/technology-overview/page-login").then(
module => {
return new module.default().bindings({
isLoggedIn: value.bind(this, "isLoggedIn")
});
}
);
}
return import(
`can/demos/technology-overview/page-${this.routeData.page}`
).then(module => {
switch (this.routeData.page) {
case "home":
return new module.default().bindings({
logout: this.logout.bind(this)
});
case "tasks":
return new module.default().bindings({
id: value.from(this.routeData, "taskId"),
logout: this.logout.bind(this)
});
default:
const page404 = document.createElement("h2");
page404.innerHTML = "Page Missing";
return page404;
}
});
},
isLoggedIn: false
};
logout() {
this.isLoggedIn = false;
}
}
customElements.define("my-app", MyApp);
The application is now progressively loading the code for each route: