Routing and Title
In Doors, the URL is reactive state. Routing is what you do with it.
The Location struct represents URL state in Doors:
// Location is a parsed or generated URL path plus query string.
type Location struct {
// Segments holds the decoded path segments without leading or trailing
// slashes.
Segments []string
// Query holds the decoded query parameters.
Query url.Values
}
Currently, our page content does not react to path changes because we have not established any binds to path state.
You can obtain doors.Source[Location] and work with it directly: derive, subscribe, bind, update, and so on.
Or use a Path Model: an annotated struct that saves a lot of boilerplate.
Path Model
Our app will serve two routes: the location selector / and the dashboard /:CityID.
Let's declare a path model that defines those two locations:
type Path struct {
Route int `path:"/ | /:CityID"`
CityID int
}
The basic syntax is very simple: annotate an exported integer field with path, then enumerate route patterns separated by |. Use :{FieldName} to capture a parameter.
The annotated field receives the index of the matched variant. In our case, Route will be either 0 or 1.
For the full syntax reference, see Routing.
To make this even more structured, let's introduce an integer enum:
type Route int
const (
Selector Route = iota
Dashboard
)
type Path struct {
Route Route `path:"/ | /:CityID"`
CityID int
}
Route Matching
Any reactive state in Doors has .Route methods that let you switch between different render branches based on the state value. URL routing is just a special case of this mechanism, and the framework ships a Route component to make it nicer.
app.gox
// path model declaration
type Path struct {
Route Route `path:"/ | /:CityID"`
CityID int
}
// Route variant enum.
type Route int
const (
Selector Route = iota
Dashboard
)
type App struct{}
elem (a App) Main() {
<!doctype html>
<html lang="en" data-theme="dark">
<head>
~/* ... */
</head>
<body>
<main class="container">
~// Routing component with a path model route.
~(doors.Route(
// Route that derives doors.Source[Path] from doors.Source[Location]
// and renders content on a successful match.
doors.RouteModel[Path](a.content),
))
</main>
</body>
</html>
}
elem (a App) content(path doors.Source[Path]) {
~/* ... */
}
Path branches into two variants: location selector and dashboard. For now, we are interested only in Selector.
Let's route it:
app.gox
elem (a App) content(path doors.Source[Path]) {
~(path.Route(
// Route based on a match function.
doors.RouteMatch(func(p Path) bool {
// Selector variant.
return p.Route == Selector
}).Comp(LocationSelector()), // Render the component on match.
))
}
If you check the web page, it seems like nothing changed. But if you check any path except /, no content will be shown on the page, because our routing matches only the Selector variant. We will add more routes soon.
Dashboard
Prepare the initial dashboard component.
dashboard.gox
package main
import (
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
func WeatherDashboard(path doors.Source[Path]) gox.Comp {
// Derive a read-only view of path with the city id.
city := doors.DeriveBeam(path, func(p Path) int {
return p.CityID
})
// Reactive Bind implements component,
// so we can return it directly.
return city.Bind(elem(cityID int) {
~dashboard{
cityID: cityID,
}
})
}
type dashboard struct {
cityID int
}
elem (d dashboard) Main() {
~{
city, _ := driver.Locations.CitiesGet(d.cityID)
}
~(if !city.IsValid() {
~// Set the page HTTP status.
~(doors.Status(404))
<h1>City Not Found</h1>
} else {
<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
})
~// Reset selection.
<a
class="secondary"
(doors.ALink{
Model: Path{Route: Selector},
})
role="button">
Change
</a>
}
Finally, route it:
app.gox
elem (a App) content(path doors.Source[Path]) {
~(path.Route(
doors.RouteMatch(func(p Path) bool {
return p.Route == Selector
}).Comp(LocationSelector(func(ctx context.Context, city int) {
path.Update(ctx, Path{
Route: Dashboard,
CityID: city,
})
})),
// Since there are only two path variants,
// we can use the RouteDefault form.
doors.RouteDefault(WeatherDashboard),
))
}
Result:

Notice how form state is maintained. That is because our LocationSelector constructor runs once during routing declaration, and the initialized state values are reused across re-renders of the returned component. We can leave it like this, but I prefer a reset form.
To fix that, wrap it in element syntax <>...</>:
elem (a App) content(path doors.Source[Path]) {
~(path.Route(
doors.RouteMatch(func(p Path) bool {
return p.Route == Selector
}).Comp(<>
~(LocationSelector(func(ctx context.Context, city int) {
path.Update(ctx, Path{
Route: Dashboard,
CityID: city,
})
}))
</>),
doors.RouteDefault(WeatherDashboard),
))
}
That makes the LocationSelector constructor run during render.
Not Found
Our main router still has one branch and will render nothing if the model patterns do not match.
Let's add basic 404 component:
app.gox
elem (a App) Main() {
~/* ... */
~(doors.Route(
doors.RouteModel(a.content),
// Add a default location route with a component.
doors.RouteLocationDefaultComp(<>
~(doors.Status(404))
<h1>Location Not Found</h1>
</>),
))
~/* ... */
}
Title
The title tag and meta tags are processed differently from other tags. Regardless of where you put them, they are moved to <head> and synchronized with the frontend.
For
<title>,<meta>, anddoors.Status(...), see Head and Status.
app.gox
type App struct{}
elem (a App) Main() {
~/* ... */
~(doors.Route(
doors.RouteModel(a.content),
doors.RouteLocationDefaultComp(<>
~// Add the not found title.
<title>Not Found</title>
~(doors.Status(404))
<h1>Location Not Found</h1>
</>),
))
~/* ... */
}
elem (a App) content(path doors.Source[Path]) {
~(path.Route(
doors.RouteMatch(func(p Path) bool {
return p.Route == Selector
}).Comp(<>
~// Add the location selector title.
<title>Select Location</title>
~(LocationSelector(func(ctx context.Context, city int) {
path.Update(ctx, Path{
Route: Dashboard,
CityID: city,
})
}))
</>),
doors.RouteDefault(WeatherDashboard),
))
}
dashboard.gox
elem (d dashboard) Main() {
~/* ... */
~(if !city.IsValid() {
~(doors.Status(404))
<title>Not Found</title>
<h1>City Not Found</h1>
} else {
<title>~(city.Name)</title>
<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
})
~/* ... */
}
This feels unusual at first, but it is actually very convenient, because you can update the title where you query the data.

Next: Scopes
Code
./app.gox
package main
import (
"context"
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
)
type Path struct {
Route Route `path:"/ | /:CityID"`
CityID int
}
type Route int
const (
Selector Route = iota
Dashboard
)
type App struct{}
elem (a App) Main() {
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link
(doors.ResourceExternal("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))
rel="stylesheet">
</head>
<body>
<main class="container">
~(doors.Route(
doors.RouteModel(a.content),
doors.RouteLocationDefaultComp(<>
<title>Not Found</title>
~(doors.Status(404))
<h1>Location Not Found</h1>
</>),
))
</main>
</body>
</html>
}
elem (a App) content(path doors.Source[Path]) {
~(path.Route(
doors.RouteMatch(func(p Path) bool {
return p.Route == Selector
}).Comp(<>
<title>Select Location</title>
~(LocationSelector(func(ctx context.Context, city int) {
path.Update(ctx, Path{
Route: Dashboard,
CityID: city,
})
}))
</>),
doors.RouteDefault(WeatherDashboard),
))
}
./location_selector.gox
package main
import (
"context"
"time"
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
type location struct {
country driver.Place
city driver.Place
}
func LocationSelector(apply func(ctx context.Context, city int)) gox.Comp {
loc := doors.NewSource(location{})
country := doors.DeriveSource(loc,
func(l location) driver.Place {
return l.country
},
func(_ location, c driver.Place) location {
return location{
country: c,
}
},
)
city := doors.DeriveSource(loc,
func(l location) driver.Place {
return l.city
},
func(l location, c driver.Place) location {
l.city = c
return l
},
)
return locationSelector{
location: loc,
city: city,
country: country,
apply: apply,
}
}
type locationSelector struct {
location doors.Source[location]
country doors.Source[driver.Place]
city doors.Source[driver.Place]
apply func(ctx context.Context, city int)
}
elem (l locationSelector) Main() {
<article>
<section>
~placeSelector{
title: "Country",
options: new(doors.Door),
search: driver.Locations.CountriesSearch,
selected: l.country,
}
</section>
~(l.country.Bind(l.selectCity))
~(l.city.Bind(l.submit))
</article>
}
elem (l locationSelector) submit(city driver.Place) {
~(if city.IsValid() {
<hr/>
<button
(doors.AClick{
Indicator: doors.IndicateAttr("aria-busy", "true"),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.apply(ctx, city.Id)
return true
},
})
id="confirm">
Confirm
</button>
~(focus("confirm"))
})
}
elem (l locationSelector) selectCity(country driver.Place) {
~(if country.IsValid() {
<section>
~placeSelector{
title: "City",
options: new(doors.Door),
search: func(input string) ([]driver.Place, error) {
return driver.Locations.CitiesSearch(country.Id, input)
},
selected: l.city,
}
</section>
})
}
type placeSelector struct {
title string
options *doors.Door
search func(input string) ([]driver.Place, error)
selected doors.Source[driver.Place]
}
elem (l placeSelector) Main() {
~(l.selected.Bind(elem(p driver.Place) {
~(if p.IsValid() {
~(l.place(p))
} else {
~(l.input())
})
}))
}
elem (l placeSelector) place(p driver.Place) {
<h3>~(l.title): <b>~(p.Name)</b></h3>
<button
(doors.AClick{
Indicator: doors.IndicateAttr("aria-busy", "true"),
Scope: new(doors.ScopeBlocking),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.selected.Update(ctx, driver.Place{})
return true
},
})
class="secondary">
Change
</button>
}
elem (l placeSelector) input() {
~{
loaderID := "loader-" + doors.IDString(l.title)
inputID := "input-" + doors.IDString(l.title)
}
<h3>Select ~(l.title)<span id=(loaderID)></span></h3>
<input
id=(inputID)
(doors.AInput{
Scope: &doors.ScopeDebounce{
Duration: 300 * time.Millisecond,
Limit: 600 * time.Millisecond,
},
Indicator: doors.IndicateAttrQuery("#" + loaderID, "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Inner(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
placeholder=(l.title)
autocomplete="off"/>
~(focus(inputID))
~{
l.options.Inner(ctx, nil)
}
~(l.options)
}
func (l placeSelector) results(input string) gox.Elem {
if len(input) == 0 {
return nil
}
if len(input) < 2 {
return <p>
<mark>Type at least two letters to search</mark>
</p>
}
results, _ := l.search(input)
if len(results) == 0 {
return <p>
<i>nothing found</i>
</p>
}
scope := new(doors.ScopeBlocking)
return <ul>
~(for _, place := range results {
<li>
<a
(doors.AClick{
Scope: scope,
PreventDefault: true,
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.selected.Update(ctx, place)
return true
},
})
href="#">
~(place.Name)
</a>
</li>
})
</ul>
}
elem focus(id string) {
<script data:id=(id)>
const id = $data("id")
const el = document.getElementById(id)
el.focus()
</script>
}
dashboard.gox
package main
import (
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
func WeatherDashboard(path doors.Source[Path]) gox.Comp {
city := doors.DeriveBeam(path, func(p Path) int {
return p.CityID
})
return city.Bind(elem(cityID int) {
~dashboard{
cityID: cityID,
}
})
}
type dashboard struct {
cityID int
}
elem (d dashboard) Main() {
~{
city, _ := driver.Locations.CitiesGet(d.cityID)
}
~(if !city.IsValid() {
~(doors.Status(404))
<title>Not Found</title>
<h1>City Not Found</h1>
} else {
<title>~(city.Name)</title>
<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
})
<a
class="secondary"
(doors.ALink{
Model: Path{Route: Selector},
})
role="button">
Change
</a>
}