Skip to content

Storage Resources

Storage Resources are classes with reactive fields that synchronize their value into either localStorage or sessionStorage.

Any existing class can be converted into a Storage Resource using the provided decorators.

ts
import { tracked } from '@glimmer/tracking';

class SiteTheme {
  @tracked explicitThemePreference: 'light' | 'dark' | null = null;
}
ts
import { LocalResource, field } from '@trail-run/core/reactive/storage-resource';

@LocalResource('site-theme')
export class SiteTheme {
  @field explicitThemePreference: 'light' | 'dark' | null = null;
}

Why Storage Resources?

No More Vanishing State!

There's nothing more annoying when using an app than not being able to return to a state you were just in. You click away for a moment, or hit refresh and suddenly you're in an entirely different state!

When that state should be shareable, it makes sense to use the URL, but not all state belongs being in the url.

Browsers offer two excellent primitives for storing device and session specific states that synchronize across tabs and windows when needed: localStorage and sessionStorage.

This library provides a reactive wrapper for these APIs as a building block for more advanced features.

Storage Resources build over these APIs, making it trivial to ensure that a user's application or session state is always persisted when needed, and synchronized into the url properly when it should be shareable.

Features

Resource Types

Storage Resources are either a LocalResource (backed by localStorage) or a Session Resource (backed by sessionStorage). The type is determined by the decorator used to setup the resource.

ts
import { LocalResource, field } from '@trail-run/core/reactive/storage-resource';

@LocalResource('home-page')
export class HomePage {
  @field scrollOffset: number = 0;
}
ts
import { SessionResource, field } from '@trail-run/core/reactive/storage-resource';

@SessionResource('home-page')
export class HomePage {
  @field scrollOffset: number = 0;
}

Field Level Resource Type Overrides

Whether a field is persisted to local or session storage can be overridden on a per-field basis. Below we show the same field configuration achieved using both a LocalResource and a SessionResource.

ts
import { LocalResource, field } from '@trail-run/core/reactive/storage-resource';

@LocalResource('home-page')
export class HomePage {
  @field showLargePreviews: boolean = false;
  @field('session') scrollOffset: number = 0;
}
ts
import { SessionResource, field } from '@trail-run/core/reactive/storage-resource';

@SessionResource('home-page')
export class HomePage {
  @field('local') showLargePreviews: boolean = false;
  @field scrollOffset: number = 0;
}

Resource Keys

Each field is stored with a key format: persisted:{resourceKey}:{fieldName}

For example, with @LocalResource('site-theme') and a field explicitThemePreference, the storage key becomes:

persisted:site-theme:explicitThemePreference

This key format is guaranteed, should you ever need to directly access the value in storage for some reason.

Static Resource Keys

ts
// site-theme here is an example of a static resource key
@LocalResource('site-theme')
class SiteTheme {}

Every instance of a Storage Resource that shares the same key shares the same underlying storage. This can be useful for singleton state and services, and even for some components. For supporting separate class instances each with their own storage use a dynamic resource key (read on below).

Dynamic Resource Keys

LocalResource and SessionResource accept a function for generating their storage key.

The generator runs once per instance the first time a storage field is used, and receives the instance as its only argument.

ts
type KeyFn = (instance: object) => string;

@LocalResource((instance: UserDashboard) => `user-${instance.userId}`)
class UserDashboard {
  userId: string;

  @field widgets: string[] = [];
  @field layout: string = 'grid';

  constructor(userId: string) {
    this.userId = userId;
  }
}

// Each user gets their own persisted dashboard
const alice = new UserDashboard('alice');
const bob = new UserDashboard('bob');

@field Field Values

Values are serialized/deserialized into strings for storage using JSON.stringify and JSON.parse, making most value types acceptable to use.

Ideally, field values should be primitives (string number boolean null), as object values will not react to inner value changes and thus will not properly reactively update or synchronize.

Default Field Values

Default values are provided via the field initializer.

ts
import { LocalResource, field } from '@trail-run/core/reactive/storage-resource';

@LocalResource('home-page')
export class HomePage {
  @field scrollOffset: number = 0; // 0 is the "default value"
}

When the field initializes, the default value initializer will run only IF no value for the field is present in storage.

Reactive Updates from other Tabs/Windows

Fields will automatically update their reactive value when changed by any other browser tab or window, thus also resulting in the recomputation of any derived state that consumes the value.

Using @effect to Respond to Cross-Tab Updates

Very occassionally, external updates result in needing to synchronize non-reactive/derived state. For instance, in order to properly theme a site for light mode vs dark mode, the mode (and typically a class) needs to be added to the document's root element or body element. Since these elements are not generally rendered via derivation, we can't rely on derived state updates to effect these changes.

One approach for these scenarios in Ember is to use a template-based modifier or helper to run a callback when the value changes.

Helper Approach
gts
import { getTheme } from '@trail-run/core/device/site-theme';

function updateSiteTheme(mode: 'light' | 'dark') {
  document.body.classList.add(mode);
  document.body.classList.remove(mode === 'light' ? 'dark' : 'light');
}

<template>
  {{updateSiteTheme theme.mode}}
</template>

But this involves manually wiring up the effect in a different file, which may not be ideal.

Instead, we can change our field into an effect.

ts
import { LocalResource, field } from '@trail-run/core/reactive/storage-resource';

@LocalResource('settings')
export class HomePage {
  @effect(syncThemeToDOM)
  mode: 'light' | 'dark' = 'light';
}

function syncThemeToDOM(this: SiteTheme): void {
  const { mode } = this;
  document.body.classList.add(mode);
  document.body.classList.remove(mode === 'light' ? 'dark' : 'light');
}

⚠️ CAUTION

Unlike the helper approach, effects only run when the value change originates from another tab or window.

Why don't effects run on every change and initialization? Often when synchronizing state the directionality matters. This approach ensures you can easily use context to achieve any desired outcome.

Turning Components into Storage Resources

Storage Resources work seamlessly with Ember Components, as these are just regular classes. We can even use component args for calculating our dynamic resource key and setting our default values.

gts
import Component from '@glimmer/component';
import { SessionResource, field } from '@trail-run/core/reactive/storage-resource';

interface LocationSignature {
  Args: {
    id: string;
    lat: number;
    lng: number;
  }
}

@SessionResource((loc: Location) => `loc:${loc.args.id}`)
export class Location extends Component<LocationSignature> {
  @field mapCenterLat = this.args.lat;
  @field mapCenterLng = this.args.lng;
}

Turning Services into Storage Resources

Storage Resources work seamlessly with Ember Services, as these are just regular classes.

ts
import Service from '@ember/service';
import { LocalResource, field } from '@trail-run/core/reactive/storage-resource';

@LocalResource('route-history')
export class HistoryService extends Service {
  @field latestRoute: string | null = null;
  @field visitCount: number = 0;
}

Using @param to turn Storage Resources into URL param sources

Any Storage Resource's fields can be used as the backing source of state for URL query params by decorating the field with additional meta information using @param.

ts
@SessionResource('map-state')
class MapState {
  @param({
    serialize: (value: boolean) => value ? '1' : null,
    deserialize: (urlValue: string) => urlValue === '1',
  })
  @field
  active: boolean = false;
}
ts

/**
 * Configuration options for fields that are also query parameters
 */
export interface ParamConfig {
  /**
   * Convert a value into a string for storage in the URL.
   * `null` indicates the value should be omitted from the URL.
   */
  serialize: (value: unknown, instance: any) => string | null;
  /**
   * Convert a string value from the URL back into
   * its original type
   */
  deserialize: (urlValue: string, instance: any) => unknown;
  /**
   * Get the default value for this param from the given instance.
   *
   * If not present, the value passed to the field initializer
   * will be used as the default.
   *
   * This should return the value in the field's native type,
   * not the serialized URL form.
   */
  getDefault?: (instance: any) => unknown;
}

💡 TIP

Configuring a field to be consumable as a param does not automatically start syncing the value to/from the URL (this is the route's responsibility, see the guide for query params).

Serialization and Deserialization does more than just enable converting values to strings for the URL.

  • Returning null from serialize indicates a param's current value shouldn't be put into the url
  • Comparisons of the url value to local values always use the serialized form of both
  • implementing getDefault enables fine-tuned control over whether an existing value is default (and thus should be omitted from the url)

The comparison behavior is especially useful. For instance, assume you wanted to serialize latitude, longitude and zoom for a map view into the url. The local values may have 14 digits of precision, which is useful for smoothing purposes but would make a URL hard to read.

http
/view?lat=37.80454055229913&lng=-122.1770635574278&zoom=13.225286550972479

Using serialization, we can cap the precision we care about for the url for each parameter, lets say 5 digits (roughly 1 meter precision) for latitude and longitude and 1 digits for zoom:

http
/view?lat=37.80454&lng=-122.17706&zoom=13.2

The result is a cleaner value for sharing in the URL while our local value remains unaffected.

Pre-Built @param configs

There are pre-built param config generators for boolean and numerical values.

ts
@SessionResource('map-state')
class MapState {
  @param(NumberParam(5))
  @field
  lat: number = 0;
}
ts
@SessionResource('map-state')
class MapState {
  @param(BooleanParam())
  @field
  active: boolean = false;
}

Configuration

Configure storage behavior before first use:

ts
import {
  configureLocalStorage,
  configureSessionStorage
} from '@trail-run/core/reactive/storage';

configureLocalStorage({
  // Fallback to in-memory storage in private browsing mode
  fallbackToMemory: true,

  // Update the in-memory reactive state even when storage quota is exceeded
  updateOnQuotaExceeded: false,

  // Handle quota exceeded errors
  onQuotaExceeded: async (key, value) => {
    // Return true to retry, false to skip
    const shouldClear = confirm('Storage full. Clear old data?');
    if (shouldClear) {
      localStorage.clear();
      return true;
    }
    return false;
  }
});

Released under the MIT License.