ember-local-storage-decorator reference — used in 32 A3 files for persisting UI state to localStorage with tracked reactivity
From a3-pluginnpx claudepluginhub trusted-american/marketplace --plugin a3-pluginThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Used in 32 A3 files. The @localStorage decorator provides a simple, reactive way to persist
UI state to the browser's localStorage. It integrates with Glimmer's tracking system so that
changes to localStorage-backed properties automatically trigger template re-renders.
Package: ember-local-storage-decorator
Import: import { localStorage } from 'ember-local-storage-decorator';
The @localStorage decorator creates a class property that:
localStorage (if a stored value exists)localStorage whenever the property is setThis is the A3 standard for persisting UI preferences that should survive page refreshes but do not belong in the database (they are user-device-specific, not user-account-specific).
@localStorage DecoratorSignature:
@localStorage(key?: string) propertyName: Type = defaultValue;
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | No | localStorage key. Defaults to the property name if omitted |
Behavior:
localStorage.getItem(key)JSON.parse and returns the stored valueJSON.stringify and calls localStorage.setItem(key, value)undefined or the default value may remove the key (implementation-dependent)import Component from '@glimmer/component';
import { localStorage } from 'ember-local-storage-decorator';
export default class SidebarComponent extends Component {
@localStorage('sidebar-collapsed')
isCollapsed: boolean = false;
}
In this example:
isCollapsed is backed by the localStorage key "sidebar-collapsed"falsetrue value is restored on reloadthis.isCollapsed is automatically persistedWhen no key argument is provided, the property name is used as the localStorage key:
export default class ThemeComponent extends Component {
@localStorage
darkMode: boolean = false;
// localStorage key: "darkMode"
}
The @localStorage decorator serializes values with JSON.stringify and deserializes with
JSON.parse. This means it supports all JSON-serializable types:
@localStorage('sidebar-collapsed')
isCollapsed: boolean = false;
@localStorage('dark-mode')
isDarkMode: boolean = false;
@localStorage('show-advanced-filters')
showAdvancedFilters: boolean = false;
@localStorage('preferred-language')
language: string = 'en';
@localStorage('last-viewed-route')
lastRoute: string = 'dashboard';
@localStorage('theme')
theme: string = 'light';
@localStorage('items-per-page')
pageSize: number = 25;
@localStorage('sidebar-width')
sidebarWidth: number = 280;
@localStorage('font-size')
fontSize: number = 14;
@localStorage('visible-columns')
visibleColumns: string[] = ['name', 'email', 'department', 'status'];
@localStorage('recent-searches')
recentSearches: string[] = [];
@localStorage('pinned-employee-ids')
pinnedIds: string[] = [];
@localStorage('table-sort')
sortConfig: { column: string; direction: 'asc' | 'desc' } = {
column: 'name',
direction: 'asc',
};
@localStorage('filter-preferences')
filters: Record<string, string> = {};
@localStorage('column-widths')
columnWidths: Record<string, number> = {};
@localStorage('last-selected-id')
lastSelectedId: string | null = null;
The most common usage in A3 — persisting sidebar collapsed/expanded state:
import Component from '@glimmer/component';
import { localStorage } from 'ember-local-storage-decorator';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class AppSidebar extends Component {
@localStorage('sidebar-collapsed')
isCollapsed: boolean = false;
@action
toggleSidebar() {
this.isCollapsed = !this.isCollapsed;
}
<template>
<aside class={{if this.isCollapsed "sidebar sidebar--collapsed" "sidebar sidebar--expanded"}}>
<button
class="sidebar-toggle"
{{on "click" this.toggleSidebar}}
aria-label={{if this.isCollapsed "Expand sidebar" "Collapse sidebar"}}
>
{{if this.isCollapsed ">" "<"}}
</button>
{{#unless this.isCollapsed}}
<nav class="sidebar-nav">
{{yield}}
</nav>
{{/unless}}
</aside>
</template>
}
Allowing users to show/hide columns and persisting their choice:
import Component from '@glimmer/component';
import { localStorage } from 'ember-local-storage-decorator';
import { action } from '@ember/object';
interface ColumnConfig {
key: string;
label: string;
visible: boolean;
}
export default class EmployeeTable extends Component {
@localStorage('employee-table-columns')
savedColumns: string[] = ['name', 'email', 'department', 'status', 'hireDate'];
@localStorage('employee-table-sort')
sortConfig: { column: string; direction: 'asc' | 'desc' } = {
column: 'name',
direction: 'asc',
};
@localStorage('employee-table-page-size')
pageSize: number = 25;
get allColumns(): ColumnConfig[] {
return [
{ key: 'name', label: 'Name', visible: this.savedColumns.includes('name') },
{ key: 'email', label: 'Email', visible: this.savedColumns.includes('email') },
{ key: 'department', label: 'Department', visible: this.savedColumns.includes('department') },
{ key: 'status', label: 'Status', visible: this.savedColumns.includes('status') },
{ key: 'hireDate', label: 'Hire Date', visible: this.savedColumns.includes('hireDate') },
{ key: 'phone', label: 'Phone', visible: this.savedColumns.includes('phone') },
{ key: 'location', label: 'Location', visible: this.savedColumns.includes('location') },
{ key: 'manager', label: 'Manager', visible: this.savedColumns.includes('manager') },
];
}
get visibleColumns(): ColumnConfig[] {
return this.allColumns.filter((col) => col.visible);
}
@action
toggleColumn(columnKey: string) {
const columns = [...this.savedColumns];
const index = columns.indexOf(columnKey);
if (index > -1) {
columns.splice(index, 1);
} else {
columns.push(columnKey);
}
this.savedColumns = columns; // Triggers localStorage write + re-render
}
@action
updateSort(column: string) {
if (this.sortConfig.column === column) {
this.sortConfig = {
column,
direction: this.sortConfig.direction === 'asc' ? 'desc' : 'asc',
};
} else {
this.sortConfig = { column, direction: 'asc' };
}
}
@action
updatePageSize(size: number) {
this.pageSize = size;
}
}
Collecting various user preferences in a single component or service:
// app/services/ui-preferences.ts
import Service from '@ember/service';
import { localStorage } from 'ember-local-storage-decorator';
export default class UiPreferencesService extends Service {
@localStorage('pref-sidebar-collapsed')
sidebarCollapsed: boolean = false;
@localStorage('pref-dark-mode')
darkMode: boolean = false;
@localStorage('pref-compact-view')
compactView: boolean = false;
@localStorage('pref-items-per-page')
itemsPerPage: number = 25;
@localStorage('pref-date-format')
dateFormat: string = 'MMM D, YYYY';
@localStorage('pref-start-page')
startPage: string = 'dashboard';
@localStorage('pref-recent-employees')
recentEmployeeIds: string[] = [];
@localStorage('pref-favorite-reports')
favoriteReports: string[] = [];
addRecentEmployee(id: string) {
const recent = [id, ...this.recentEmployeeIds.filter((eid) => eid !== id)].slice(0, 10);
this.recentEmployeeIds = recent;
}
toggleFavoriteReport(reportId: string) {
const favorites = [...this.favoriteReports];
const index = favorites.indexOf(reportId);
if (index > -1) {
favorites.splice(index, 1);
} else {
favorites.push(reportId);
}
this.favoriteReports = favorites;
}
}
Persisting whether filter panels are expanded and what filters were last used:
import Component from '@glimmer/component';
import { localStorage } from 'ember-local-storage-decorator';
import { action } from '@ember/object';
export default class EmployeeFilters extends Component {
@localStorage('employee-filters-expanded')
isExpanded: boolean = true;
@localStorage('employee-filters-department')
selectedDepartment: string = '';
@localStorage('employee-filters-status')
selectedStatus: string = 'active';
@localStorage('employee-filters-location')
selectedLocation: string = '';
@action
toggleExpanded() {
this.isExpanded = !this.isExpanded;
}
@action
updateDepartment(dept: string) {
this.selectedDepartment = dept;
this.args.onFilterChange?.(this.currentFilters);
}
@action
updateStatus(status: string) {
this.selectedStatus = status;
this.args.onFilterChange?.(this.currentFilters);
}
@action
clearFilters() {
this.selectedDepartment = '';
this.selectedStatus = 'active';
this.selectedLocation = '';
this.args.onFilterChange?.(this.currentFilters);
}
get currentFilters() {
return {
department: this.selectedDepartment,
status: this.selectedStatus,
location: this.selectedLocation,
};
}
}
Tracking which tours or onboarding steps a user has completed:
import Service from '@ember/service';
import { localStorage } from 'ember-local-storage-decorator';
export default class OnboardingService extends Service {
@localStorage('completed-tours')
completedTours: string[] = [];
@localStorage('dismissed-banners')
dismissedBanners: string[] = [];
hasTourCompleted(tourId: string): boolean {
return this.completedTours.includes(tourId);
}
completeTour(tourId: string) {
if (!this.completedTours.includes(tourId)) {
this.completedTours = [...this.completedTours, tourId];
}
}
isBannerDismissed(bannerId: string): boolean {
return this.dismissedBanners.includes(bannerId);
}
dismissBanner(bannerId: string) {
if (!this.dismissedBanners.includes(bannerId)) {
this.dismissedBanners = [...this.dismissedBanners, bannerId];
}
}
resetAllTours() {
this.completedTours = [];
}
}
import Service from '@ember/service';
import { localStorage } from 'ember-local-storage-decorator';
interface RecentItem {
id: string;
name: string;
type: string;
viewedAt: string; // ISO string (dates are stored as strings in JSON)
}
export default class RecentItemsService extends Service {
@localStorage('recent-items')
items: RecentItem[] = [];
addItem(id: string, name: string, type: string) {
const filtered = this.items.filter((item) => item.id !== id);
const updated = [
{ id, name, type, viewedAt: new Date().toISOString() },
...filtered,
].slice(0, 20); // Keep last 20
this.items = updated;
}
getByType(type: string): RecentItem[] {
return this.items.filter((item) => item.type === type);
}
clear() {
this.items = [];
}
}
The @localStorage decorator integrates with Glimmer's tracking system. This means:
Templates auto-update: When you set a @localStorage property, any template that reads
it will re-render automatically.
Getters that depend on localStorage properties are reactive:
@localStorage('visible-columns')
savedColumns: string[] = ['name', 'email'];
// This getter re-computes when savedColumns changes
get columnCount(): number {
return this.savedColumns.length;
}
The property is tracked under the hood. You do not need @tracked in addition to
@localStorage. Using both is redundant (and may cause issues).
Like @tracked, reactivity for objects and arrays requires creating a new reference. Mutating
in place does NOT trigger re-renders:
// WRONG — mutates in place, no re-render, no localStorage write
this.savedColumns.push('phone');
// RIGHT — creates new array, triggers re-render + localStorage write
this.savedColumns = [...this.savedColumns, 'phone'];
// WRONG — mutates object in place
this.sortConfig.direction = 'desc';
// RIGHT — creates new object
this.sortConfig = { ...this.sortConfig, direction: 'desc' };
This is the single most common mistake with @localStorage in A3.
Use a consistent, descriptive key naming pattern to avoid collisions:
// Pattern: {section}-{component}-{property}
@localStorage('employees-table-columns') // Employee table column visibility
@localStorage('employees-table-sort') // Employee table sort state
@localStorage('employees-table-page-size') // Employee table pagination
@localStorage('employees-filters-expanded') // Employee filter panel state
@localStorage('reports-sidebar-collapsed') // Reports sidebar state
@localStorage('admin-settings-tab') // Last selected admin settings tab
Since localStorage is shared across the entire origin (domain + port):
Prefix with app name if multiple apps share a domain:
@localStorage('a3-sidebar-collapsed')
Include entity context for entity-specific preferences:
@localStorage(`employee-table-${companyId}-columns`)
Note: Dynamic keys require special handling since decorators run at class definition time.
For dynamic keys, use plain localStorage.getItem/setItem with @tracked manually.
Document all keys in a central location to prevent accidental reuse.
// Setting to default value (may or may not remove the key)
this.isCollapsed = false;
// Explicitly remove from localStorage
localStorage.removeItem('sidebar-collapsed');
// Note: The @localStorage property will still hold its current in-memory value
// until the component is re-created
// Clear ALL localStorage (use carefully — affects ALL apps on this origin)
localStorage.clear();
// Clear only A3 keys (if prefixed)
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith('a3-') || key?.startsWith('pref-')) {
localStorage.removeItem(key);
}
}
// app/services/ui-preferences.ts
export default class UiPreferencesService extends Service {
// ... localStorage properties ...
resetAll() {
// Reset to defaults by setting each property
this.sidebarCollapsed = false;
this.darkMode = false;
this.compactView = false;
this.itemsPerPage = 25;
this.dateFormat = 'MMM D, YYYY';
this.recentEmployeeIds = [];
this.favoriteReports = [];
}
}
In tests, localStorage persists between test runs (within the same browser session). Always clean up in test setup/teardown:
// tests/helpers/setup-local-storage.ts
export function setupLocalStorage(hooks: NestedHooks) {
hooks.beforeEach(function () {
// Store original state
this.originalStorage = { ...localStorage };
localStorage.clear();
});
hooks.afterEach(function () {
localStorage.clear();
// Restore original state if needed
Object.entries(this.originalStorage).forEach(([key, value]) => {
localStorage.setItem(key, value as string);
});
});
}
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
module('Integration | Component | sidebar', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
localStorage.clear();
});
hooks.afterEach(function () {
localStorage.clear();
});
test('it restores collapsed state from localStorage', async function (assert) {
// Seed the stored state
localStorage.setItem('sidebar-collapsed', 'true');
await render(hbs`<AppSidebar />`);
assert.dom('.sidebar').hasClass('sidebar--collapsed');
});
test('it defaults to expanded when no stored state', async function (assert) {
await render(hbs`<AppSidebar />`);
assert.dom('.sidebar').hasClass('sidebar--expanded');
});
test('it persists collapsed state on toggle', async function (assert) {
await render(hbs`<AppSidebar />`);
await click('.sidebar-toggle');
assert.strictEqual(localStorage.getItem('sidebar-collapsed'), 'true');
});
});
localStorage is a browser-only API. It does not exist in Node.js / FastBoot environments.
If A3 ever adopts server-side rendering (SSR) via FastBoot or similar:
localStorage is accessed during SSR.import { isFastBoot } from 'ember-cli-fastboot/utils';
// Or check directly
const hasLocalStorage = typeof window !== 'undefined' && window.localStorage;
shoebox for passing server-side state
and only initialize @localStorage properties on the client side.Currently A3 does NOT use SSR, so this is not a concern, but it is worth noting for future-proofing.
| Browser | localStorage Limit |
|---|---|
| Chrome | 5 MB per origin |
| Firefox | 5 MB per origin |
| Safari | 5 MB per origin |
| Edge | 5 MB per origin |
5 MB is approximately 2.5 million characters of JSON. For UI preferences, this is more than sufficient. A3 typically uses less than 10 KB total.
localStorage.setItem can throw QuotaExceededError if the storage is full. The decorator
may or may not handle this gracefully. For safety in critical paths:
@action
addRecentItem(item: RecentItem) {
try {
this.recentItems = [item, ...this.recentItems.slice(0, 49)];
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
// Storage full — clear old data
this.recentItems = [item];
} else {
throw error;
}
}
}
Some browsers (notably older Safari) throw errors when writing to localStorage in private
browsing mode. Modern browsers generally allow it but clear storage when the session ends.
The @localStorage decorator handles this gracefully by falling back to in-memory storage.
Sensitive data — Never store tokens, passwords, PII, or session data. Use secure cookies or session storage for auth tokens.
Large datasets — If the data exceeds a few KB, consider IndexedDB or the server.
Cross-device state — localStorage is device-specific. For preferences that should follow the user across devices, store in Firestore on the user document.
Shared state — If multiple browser tabs need to stay in sync, consider the
StorageEvent API or a shared service worker. The @localStorage decorator does NOT
automatically sync across tabs.
Non-serializable values — Functions, class instances, Symbols, Date objects (stored as
strings), undefined (dropped by JSON.stringify), circular references.
| Concern | @tracked | @localStorage | Firestore |
|---|---|---|---|
| Persistence | None (lost on refresh) | Browser only | Server (permanent) |
| Cross-device | No | No | Yes |
| Cross-tab | No | No (without extra work) | Yes (with listener) |
| Performance | Fastest | Fast (sync I/O) | Slow (async network) |
| Use case | Ephemeral UI state | Device-specific preferences | Business data, shared state |
| Examples | Dropdown open state, form dirty state | Sidebar collapsed, column prefs | Employee records, settings |
@tracked. Yes -> continue.@localStorage. Yes -> Firestore.@localStorage is fine.import { localStorage } from 'ember-local-storage-decorator';
// Boolean with explicit key
@localStorage('sidebar-collapsed')
isCollapsed: boolean = false;
// String with auto key
@localStorage
theme: string = 'light';
// Number
@localStorage('page-size')
pageSize: number = 25;
// Array (always update immutably!)
@localStorage('visible-cols')
columns: string[] = ['name', 'email'];
// Object (always update immutably!)
@localStorage('sort-config')
sort: { col: string; dir: string } = { col: 'name', dir: 'asc' };
// Nullable
@localStorage('last-id')
lastId: string | null = null;