Accessible progressive UI
Persist only user-owned state
Store preferences and drafts deliberately without turning browser storage into an accidental database.
localStorage is one line to write and one line to read. That’s why it fills up with things that don’t belong there.
The test I use: does this value belong to the user’s browser, or to the application? The filter the user picked last time belongs to the browser. The list of issues belongs to the server. Persist the first. Never the second.
What’s worth persisting on the board
Two things. The last status filter, so the board opens the way you left it. And an unsaved editor draft, so a closed tab doesn’t lose a paragraph of typing.
Not the issues. Not the result count. Not whether a row was expanded. All of those either come from the server or aren’t worth remembering.
Write it with a watcher
Persistence is a boundary, so it lives in init() and a watcher, like the URL sync:
Alpine.data('issueFilter', () => ({
status: '',
init() {
this.status = this.readSavedStatus()
this.$watch('status', value => {
localStorage.setItem('issue-filter', JSON.stringify({ v: 1, status: value }))
})
},
readSavedStatus() {
try {
const saved = JSON.parse(localStorage.getItem('issue-filter'))
if (saved?.v !== 1) return ''
if (!['', 'open', 'closed'].includes(saved.status)) return ''
return saved.status
} catch {
return ''
}
}
}))
Pick Closed, reload, and the dropdown still says Closed. Open the Application panel in devtools and you’ll see the key: {"v":1,"status":"closed"}.
Validate every read
Look at how much of that code is the read path. That’s on purpose. Storage is user-controlled input. It can be missing, corrupted, edited by hand, or written by an old version of your page.
The try catches invalid JSON. The v check rejects formats you no longer understand. The allow-list rejects a status value your <select> doesn’t have. Any failure falls back to the default, and the board starts normally.
Skip this and one bad value breaks the page for that user forever, because a reload reads the same bad value again.
Version the format
The v: 1 field costs nothing now and saves you later. When the filter grows a sort field, you bump to v: 2, and the read path knows a v: 1 record should be dropped or upgraded. Without it, you’re guessing from the shape.
Give users a way out
Add a Reset button that clears the key and the state:
<button type="button" @click="localStorage.removeItem('issue-filter'); status = ''">
Reset filters
</button>
Nobody should have to open devtools to get back to defaults.
The Persist plugin
Alpine’s Persist plugin does the write-and-read in one call, $persist(''). It’s fine for a boolean like “sidebar collapsed”. It doesn’t validate or version, so for anything with a shape I write the few lines above.
Try the hostile cases on your board. In the console, set the key to not json, then to {"v":1,"status":"deleted"}, then to a v: 0 record. Reload after each. The board should come up with the default filter every time.
Lesson completed