URL-Preserved Filters

The filter and sort state lives in the URL's query string, so a filtered view is bookmarkable and survives a reload. Filter links are SPA navigations that update the history entry; Mount reads status and sort from the query params on every GET and recomputes the list, falling back to defaults for unknown values so stale bookmarks still render.

URL-Preserved Filters

Filter state lives in the URL. SPA link clicks update history; direct-loading a bookmark restores the view. Mount() reads query params.

Current: status=all, sort=name

NameStatusDate
Audit access controls completed 2024-08-19
Design homepage completed 2024-02-14
Draft Q1 proposal completed 2024-03-01
Host team offsite completed 2024-06-12
Launch mobile app active 2024-11-11
Migrate legacy database active 2024-04-02
Refactor billing module active 2024-09-28
Review authentication spec active 2024-03-15
Ship payments beta active 2024-07-04
Upgrade CI pipeline active 2024-05-08
Write onboarding docs completed 2024-04-20
Year-end retrospective active 2024-12-30

Template

The filter and sort controls are ordinary <a href="?status=…&sort=…"> links — each one carries the full query string and marks itself aria-current="page" when active. The table ranges over .Items.

{{define "content"}}
<article>
    <h3>URL-Preserved Filters</h3>
    <p><small>Filter state lives in the URL. SPA link clicks update history; direct-loading a bookmark restores the view. Mount() reads query params.</small></p>
    <p><small>Current: status=<code>{{.Status}}</code>, sort=<code>{{.Sort}}</code></small></p>
    <nav>
        <ul>
            <li><a href="?status=all&amp;sort={{.Sort}}" {{if eq .Status "all"}}aria-current="page"{{end}}>All</a></li>
            <li><a href="?status=active&amp;sort={{.Sort}}" {{if eq .Status "active"}}aria-current="page"{{end}}>Active</a></li>
            <li><a href="?status=completed&amp;sort={{.Sort}}" {{if eq .Status "completed"}}aria-current="page"{{end}}>Completed</a></li>
        </ul>
        <ul>
            <li><a href="?status={{.Status}}&amp;sort=name" {{if eq .Sort "name"}}aria-current="page"{{end}}>By Name</a></li>
            <li><a href="?status={{.Status}}&amp;sort=date" {{if eq .Sort "date"}}aria-current="page"{{end}}>By Date</a></li>
        </ul>
    </nav>
    <table>
        <thead><tr><th>Name</th><th>Status</th><th>Date</th></tr></thead>
        <tbody>
        {{range .Items}}
        <tr data-key="{{.ID}}">
            <td>{{.Name}}</td>
            <td>{{.Status}}</td>
            <td>{{.Date}}</td>
        </tr>
        {{end}}
        </tbody>
    </table>
    {{if eq (len .Items) 0}}
    <p><small>No items match this filter.</small></p>
    {{end}}
</article>
{{end}}

url-filters.tmpl

Handler & state

Mount only reads query params on a GET navigation (ctx.Action() == ""), validates them against allow-lists, and always recomputes Items so both the initial render and later actions see fresh data.

type URLFiltersController struct{}

// validStatuses / validSorts allow Mount to reject unknown query param values
// without crashing: unknown values fall back to the previous/default state
// rather than producing a 404 or an error. Bookmarks with stale params still
// render usefully.
var (
	validStatuses = map[string]bool{"all": true, "active": true, "completed": true}
	validSorts    = map[string]bool{"name": true, "date": true}
)

func (c *URLFiltersController) Mount(state URLFiltersState, ctx *livetemplate.Context) (URLFiltersState, error) {
	// ctx.Action() == "" means this is a GET navigation (initial load or SPA
	// link click), not a POST action. Only read URL query params on GET to
	// avoid clobbering state from an in-flight action.
	if ctx.Action() == "" {
		if s := ctx.GetString("status"); s != "" && validStatuses[s] {
			state.Status = s
		}
		if s := ctx.GetString("sort"); s != "" && validSorts[s] {
			state.Sort = s
		}
	}
	// Always recompute the item list so the initial render (with defaults)
	// and any subsequent action both see fresh data.
	state.Items = filterItems(state.Status, state.Sort)
	return state, nil
}

func urlFiltersHandler() http.Handler {
	tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/search/url-filters.tmpl")
	return tmpl.Handle(&URLFiltersController{}, livetemplate.AsState(&URLFiltersState{
		Title:    "URL-Preserved Filters",
		Category: "Search & Filtering",
		Status:   "all",
		Sort:     "name",
	}))
}

handlers_search.go:41-79

// URLFiltersState holds the state for the URL-Preserved Filters pattern (#13).
type URLFiltersState struct {
	Title    string
	Category string
	Status   string
	Sort     string
	Items    []FilterItem
}

state_search.go:15-23

When to use

Reach for Active Search when filtering is driven by free-text input rather than a fixed set of links.

source: livetemplate/docs · path: examples/patterns/templates/search/url-filters.tmpl