How to persist user preferences in localStorage from JS in WordPress

Contents

Introduction

Persisting user preferences in localStorage from JavaScript is a common, performant, and privacy-friendly way to remember UI choices on the client side. In a WordPress context you’ll often combine client-side persistence for fast UI feedback with server-side persistence (usermeta, options, or REST) for cross-device sync. This article covers every practical detail: API fundamentals, patterns, pitfalls, performance, error handling, fallback strategies, synchronization across tabs, versioning and migrations, expiration emulation, WordPress enqueue patterns, and production-ready utility code.

When to use localStorage

  • Use it for non-sensitive UI preferences: theme (light/dark), expanded/collapsed panels, page-level filters, UI density, last active tab, etc.
  • Avoid it for secrets: API keys, passwords, tokens—these belong in secure cookies or server-side storage.
  • Prefer server persistence when
    • you need preferences to follow the user across different devices (use usermeta or an API)
    • you must audit or centrally manage preferences

Basic localStorage rules and behavior

  • localStorage is synchronous and keyed by string keys. Values are strings use JSON.stringify/JSON.parse to store objects.
  • Storage quota is implementation-dependent (roughly 5MB per origin on most browsers).
  • localStorage persists across tabs and browser restarts for the same origin.
  • In private browsing some browsers restrict or disable localStorage (wrap access in try/catch).
  • Changes in one tab emit a storage event in other tabs on the same origin, enabling cross-tab sync.

Essential best practices

  1. Namespace your keys to avoid collisions. Example: myplugin:preferences:v1:theme.
  2. Version your schema so you can migrate old formats: include a version number in the stored object or key.
  3. Always use try/catch to handle disabled storage or QuotaExceededError.
  4. Debounce writes if preferences update frequently (typing, sliders) to avoid blocking the main thread.
  5. Validate and sanitize values read from storage before using them.
  6. Do not store secrets.
  7. Provide fallback defaults for missing keys or malformed data.
  8. Consider privacy consent: if GDPR requires, consider consent before long-term storage for analytics-type preferences.

Simple example: save a theme preference (light/dark)

This minimal, production-oriented snippet demonstrates storing a theme choice, applying it on load, and keeping a namespaced key. All example code blocks are placed in the required EnlighterJSRAW pre tags.

// localStorage-theme.js
(function () {
  var KEY = mytheme:preferences:v1:theme
  var DEFAULT = light

  function storageAvailable() {
    try {
      var test = __storage_test__
      window.localStorage.setItem(test, test)
      window.localStorage.removeItem(test)
      return true
    } catch (e) {
      return false
    }
  }

  function getTheme() {
    if (!storageAvailable()) return DEFAULT
    try {
      var v = window.localStorage.getItem(KEY)
      return v ? v : DEFAULT
    } catch (e) {
      return DEFAULT
    }
  }

  function setTheme(value) {
    if (!storageAvailable()) return
    try {
      window.localStorage.setItem(KEY, value)
    } catch (e) {
      // Handle QuotaExceededError or disabled storage
      console.warn(localStorage set failed, e)
    }
  }

  // Apply theme when script loads
  var theme = getTheme()
  document.documentElement.setAttribute(data-theme, theme)

  // Expose helper for UI control (e.g., onclick of a toggle button)
  window.myTheme = {
    get: getTheme,
    set: function (value) {
      setTheme(value)
      document.documentElement.setAttribute(data-theme, value)
    }
  }
})()

Debouncing writes

When preferences update frequently (e.g., while dragging a slider or typing), call localStorage sparingly. A simple debounce function prevents blocking the main thread on every keystroke.

// debounce.js
function debounce(fn, wait) {
  var t
  return function () {
    var args = arguments
    clearTimeout(t)
    t = setTimeout(function () {
      fn.apply(null, args)
    }, wait)
  }
}

// Usage: debouncedSave(value)
var savePreference = function(val) {
  try {
    localStorage.setItem(myplugin:pref:v1:filter, JSON.stringify(val))
  } catch (e) {
    console.warn(save failed, e)
  }
}
var debouncedSave = debounce(savePreference, 300)

Cross-tab synchronization

listening to the storage event lets you mirror preferences across open tabs in real time. The event does not fire in the same tab that made the change.

// sync-across-tabs.js
window.addEventListener(storage, function (e) {
  // e.key, e.newValue, e.oldValue
  if (e.key === myplugin:preferences:v1:theme) {
    var newTheme = e.newValue  light
    document.documentElement.setAttribute(data-theme, newTheme)
    // optionally notify UI controls to update state
  }
})

Storing objects and arrays

Always JSON.stringify on set and JSON.parse on get. Wrap parsing in try/catch and validate the shape of data.

// object-storage.js
var KEY = myplugin:preferences:v1:settings

function saveSettings(obj) {
  try {
    localStorage.setItem(KEY, JSON.stringify(obj))
  } catch (e) {
    console.warn(saveSettings failed, e)
  }
}

function loadSettings() {
  try {
    var s = localStorage.getItem(KEY)
    return s ? JSON.parse(s) : null
  } catch (e) {
    console.warn(loadSettings parse failed, e)
    return null
  }
}

Namespacing, versioning and migration

Include a version string in the key or in the stored payload. Provide an upgrade path if the structure changes.

// migration.js
var KEY_V1 = myplugin:preferences:v1:settings
var KEY_V2 = myplugin:preferences:v2:settings

function migrateV1toV2() {
  try {
    var raw = localStorage.getItem(KEY_V1)
    if (!raw) return // nothing to migrate
    var old = JSON.parse(raw)
    // Example migration: move accent into theme object
    var newObj = {
      theme: {
        mode: old.mode  light,
        accent: old.accent  blue
      },
      other: old.other  {}
    }
    localStorage.setItem(KEY_V2, JSON.stringify(newObj))
    localStorage.removeItem(KEY_V1)
  } catch (e) {
    console.warn(migration failed, e)
  }
}

// Call migrate early in boot process
migrateV1toV2()

Emulating expiration (TTL)

localStorage has no native TTL. Store a timestamp alongside the payload and check it when reading.

// ttl-wrapper.js
var KEY = myplugin:preferences:v1:cachedData

function setWithTTL(key, value, ttlSeconds) {
  var payload = {
    data: value,
    expiresAt: Date.now()   ttlSeconds  1000
  }
  try {
    localStorage.setItem(key, JSON.stringify(payload))
  } catch (e) {
    console.warn(setWithTTL failed, e)
  }
}

function getWithTTL(key) {
  try {
    var raw = localStorage.getItem(key)
    if (!raw) return null
    var payload = JSON.parse(raw)
    if (payload.expiresAt  Date.now() > payload.expiresAt) {
      localStorage.removeItem(key)
      return null
    }
    return payload.data
  } catch (e) {
    console.warn(getWithTTL parse failed, e)
    return null
  }
}

Error handling and fallback strategies

  • Always guard localStorage access with try/catch.
  • If localStorage is unavailable, use an in-memory fallback object for the session so UI still responds.
  • Detect QuotaExceededError to inform users or free old items (namespaced cleanup routine).
  • Cookie fallback is possible for tiny values, but cookies are sent with requests (bandwidth/privacy) so prefer in-memory or server fallback for larger or sensitive data.

Security and privacy considerations

  • Never store credentials or secrets in localStorage.
  • Dont assume data from localStorage is trustworthy—always validate before use.
  • XSS is the main risk: malicious script can read localStorage. Mitigate XSS with proper output escaping, CSP, and avoiding injecting unsafe HTML.
  • Be mindful of privacy laws: persistent client-side storage may require user consent depending on jurisdiction and the nature of data.

WordPress-specific integration patterns

On WordPress, you typically ship the client-side JS via wp_enqueue_script and optionally pass initial server values with wp_localize_script or wp_add_inline_script. For persistent, cross-device preferences save to usermeta (for logged-in users) or options (site-wide), or expose a REST endpoint to persist settings.

Enqueuing a script and passing initial data

Use wp_enqueue_script and wp_localize_script (or wp_add_inline_script) to provide initial values (e.g., default or user-saved preferences) to your JS.

// functions.php (or plugin main file)
function myplugin_enqueue_scripts() {
  wp_enqueue_script(
    myplugin-prefs,
    plugin_dir_url(__FILE__) . assets/js/localStorage-theme.js,
    array(),
    1.0.0,
    true
  )

  // Pass server-side user preference for initial state (logged-in)
  prefs = array(
    theme => light, // calculated server-side or from get_user_meta
  )
  wp_localize_script(myplugin-prefs, MyPluginPrefs, prefs)
}
add_action(wp_enqueue_scripts, myplugin_enqueue_scripts)

Saving to server for logged-in users

For cross-device persistence, send preferences to the server via the REST API or admin-ajax. Save to usermeta for per-user defaults. Keep localStorage for instant UI updates and sync to server in the background.

// js: send to REST API (simplified)
function saveToServer(prefs) {
  fetch(/wp-json/myplugin/v1/userprefs, {
    method: POST,
    credentials: same-origin,
    headers: {
      Content-Type: application/json,
      X-WP-Nonce: MyPluginPrefs.nonce // generated via wp_create_nonce in PHP
    },
    body: JSON.stringify({ prefs: prefs })
  }).then(function (res) {
    return res.json()
  }).then(function (data) {
    // handle response
  }).catch(function (err) {
    console.warn(server save failed, err)
  })
}
// PHP: register a simple REST route that uses current_user to save user meta
add_action(rest_api_init, function () {
  register_rest_route(myplugin/v1, /userprefs, array(
    methods => POST,
    permission_callback => function () {
      return is_user_logged_in()
    },
    callback => function (WP_REST_Request request) {
      user_id = get_current_user_id()
      params = request->get_json_params()
      if (!isset(params[prefs])) {
        return new WP_Error(no_prefs, No prefs provided, array(status => 400))
      }
      update_user_meta(user_id, myplugin_prefs, params[prefs])
      return rest_ensure_response(array(success => true))
    }
  ))
})

Offline sync strategy

Combine localStorage for instant local updates with background sync to server. If network is unavailable, queue changes in localStorage with a timestamp and push when online. Respect conflict resolution rules: last-write-wins or server-authoritative, depending on use case.

// offline-queue.js
var QUEUE_KEY = myplugin:queue:v1

function queueAction(action) {
  try {
    var q = JSON.parse(localStorage.getItem(QUEUE_KEY)  [])
    q.push({ action: action, ts: Date.now() })
    localStorage.setItem(QUEUE_KEY, JSON.stringify(q))
  } catch (e) {
    console.warn(queueAction failed, e)
  }
}

function flushQueue() {
  var q = JSON.parse(localStorage.getItem(QUEUE_KEY)  [])
  if (!q.length) return
  // attempt to send, then clear only upon success
  fetch(/wp-json/myplugin/v1/userprefs/batch, {/.../})
    .then(function (res) { / on success: / localStorage.removeItem(QUEUE_KEY) })
    .catch(function () { / keep queue for later / })
}

Performance notes

  • localStorage operations are synchronous avoid large writes on hot UI paths.
  • Prefer batching multiple small preferences into a single JSON object write.
  • Debounce or throttle frequent updates.
  • Compress/shorten key names where appropriate, but keep readability for debugging.

Testing and debugging tips

  • Use the browser devtools Application tab to inspect and clear localStorage keys.
  • Simulate full storage by repeatedly writing until QuotaExceededError to test fallback
  • Test in private/incognito modes and across browsers because behavior varies.
  • Verify storage event behavior by opening multiple tabs of the same origin and making updates in one.

Complete production-ready helper module

A compact, resilient utility wrapping common patterns: namespacing, versioning, JSON serialization, TTL, and in-memory fallback when localStorage is unavailable.

// prefs-util.js
var PrefsUtil = (function () {
  function _storageAvailable() {
    try {
      var x = __s_test__
      localStorage.setItem(x, x)
      localStorage.removeItem(x)
      return true
    } catch (e) {
      return false
    }
  }

  var hasStorage = _storageAvailable()
  var memFallback = {}

  function _getRaw(key) {
    if (hasStorage) {
      try { return localStorage.getItem(key) }
      catch (e) { console.warn(localStorage.get failed, e) return null }
    }
    return memFallback[key]  null
  }

  function _setRaw(key, value) {
    if (hasStorage) {
      try { localStorage.setItem(key, value) return true }
      catch (e) { console.warn(localStorage set failed, e) return false }
    }
    memFallback[key] = value
    return true
  }

  function set(key, value) {
    try {
      _setRaw(key, JSON.stringify(value))
    } catch (e) {
      console.warn(PrefsUtil.set error, e)
    }
  }

  function get(key, defaultValue) {
    try {
      var raw = _getRaw(key)
      if (!raw) return defaultValue
      return JSON.parse(raw)
    } catch (e) {
      console.warn(PrefsUtil.get parse error, e)
      return defaultValue
    }
  }

  function remove(key) {
    if (hasStorage) {
      try { localStorage.removeItem(key) }
      catch (e) { delete memFallback[key] }
    } else {
      delete memFallback[key]
    }
  }

  function setWithTTL(key, value, ttlSeconds) {
    set(key, { __ttl: Date.now()   ttlSeconds  1000, v: value })
  }

  function getWithTTL(key, defaultValue) {
    var o = get(key, null)
    if (!o) return defaultValue
    if (o.__ttl  Date.now() > o.__ttl) {
      remove(key)
      return defaultValue
    }
    return o.v !== undefined ? o.v : defaultValue
  }

  return {
    set: set,
    get: get,
    remove: remove,
    setWithTTL: setWithTTL,
    getWithTTL: getWithTTL
  }
})()

Summary checklist for production

  1. Namespace keys and include schema versioning.
  2. Guard all localStorage access with try/catch and provide an in-memory fallback.
  3. Use JSON for objects and validate parsed data.
  4. Debounce frequent writes and batch smaller pieces into single writes where possible.
  5. Use storage event to synchronize across tabs.
  6. Do not store secrets sanitize data and mitigate XSS.
  7. For cross-device persistence, mirror local changes to the server (REST, usermeta, options) and plan conflict resolution.
  8. Consider privacy/consent requirements depending on your jurisdiction and data type.

References and further reading

Closing notes

Using localStorage correctly gives your WordPress UI fast, resilient preference persistence. Combine good client-side practices with server-side persistence where required, and always code defensively for storage availability and data integrity. The code snippets above provide ready-to-use utilities and patterns suitable for production deployment.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *