# Localstorage Vs Url State

> Use when deciding where new UI state should live — URL params or localStorage. Rule of thumb operator-preference state (favorites, picture-in-picture geometry, panel layout, theme) goes in localStorage; chart-content state (symbol, granularity, indicator config) goes in the URL so links are shareable. Includes the cross-component reactivity pattern (custom event + storage event) and the watchlist-navigation trap that motivated the rule.

- Skill: `traderspost/localstorage-vs-url-state` (Agent Skill)
- Install (CLI): `npx skillmds@latest add traderspost/localstorage-vs-url-state`
- Raw SKILL.md: https://api.skillmd.com/api/skills/traderspost/localstorage-vs-url-state/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: traderspost (https://skillmd.com/u/traderspost)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/traderspost/localstorage-vs-url-state

---


# localStorage vs URL — where new UI state belongs

Every new UI feature that has settings needs an answer to "where does that state live?" Get this wrong and one of two things happens:

1. **State in URL when it should be localStorage**: in-app navigation (clicking a watchlist symbol, switching to a different chart) drops the query string and your feature disappears.
2. **State in localStorage when it should be URL**: links don't share what the operator is looking at; bookmarks open a different view than expected; the operator can't link a teammate to "exactly this configuration."

Both bugs are easy to ship, easy to miss in testing, and frustrating in production.

## When to use

Reach for this skill if you're:

- Adding a new toggle, picker, or saved-list to a chart UI
- Hearing "the X disappeared when I clicked Y" from a user
- Designing the URL/state surface for a new feature
- Auditing where existing settings live and finding the split inconsistent

## The pattern — three categories

### URL params: chart content + shareable view state

If a teammate would pass you the URL and expect to see the SAME thing, it goes in the URL.

- Symbol (`?symbol=NQM6`)
- Granularity (`?g=5min`)
- Indicator instances and their params (`?vwap=anchor:D,color:18dcff|...`)
- Date range or backtest window (`?from=2026-01-01&to=2026-06-30`)
- Strategy under inspection (`?strategy=ma_cross_v1`)

The URL codec for each of these should be tight enough to read in a bookmark and survive being typed by hand. Use compact tokens, not JSON.

### localStorage: operator preferences + layout

If it's a personal preference that should NOT be in shared URLs, localStorage is the right home.

- Pinned timeframe favorites in the toolbar
- Picture-in-picture geometry (x, y, w, h) + visibility + mirror toggle
- Right-rail panel toggle states
- Theme preference (dark/light/system)
- "I dismissed this banner / completed this onboarding step"

Use a per-feature key (e.g. `myapp.pipSettings.v1`, `myapp.timeframeFavorites.v1`) with a version suffix so you can migrate later without name collision.

### URL `vs` localStorage — the deciding question

When in doubt, ask: **if the operator sends this URL to a teammate, should the teammate see the same thing?**

- Same chart, same indicators, same timeframe → URL.
- Different teammates can have different pinned timeframes, different PiP positions → localStorage.

## The trap: URL state + in-app navigation

A common failure: a feature gets URL-based state (e.g. `?pip=on,4hour`) and works great when accessed directly. Then the operator clicks a watchlist link — `<a href="/?symbol=ESM6">` — and the feature DISAPPEARS. Why? Because the watchlist link is a plain anchor that REPLACES the query string with just `?symbol=ESM6`. The `?pip=` token is dropped.

Two ways to fix this:

1. **Move state to localStorage** — operator preferences don't belong in URLs anyway.
2. **Make every internal link preserve query params** — touches every navigation site-wide, lots of places to forget.

Option 1 is dramatically cleaner. The signal "this is an operator preference" answers the question for you.

## Cross-component reactivity in localStorage

The classic pain point: component A writes to localStorage; component B reads it. B doesn't re-render when A writes — localStorage updates don't trigger React. Fix: dispatch a custom event on write, listen for it (plus the native `storage` event for multi-tab support):

```ts
"use client";
import { useCallback, useEffect, useState } from "react";

const STORAGE_KEY = "myapp.feature.v1";
const STORAGE_EVENT = "myapp.feature.changed";

export function useFeatureSettings() {
  const [settings, setSettings] = useState(() => readStorage());

  useEffect(() => {
    const onChange = () => setSettings(readStorage());
    window.addEventListener(STORAGE_EVENT, onChange);
    // Also react to other-tab updates.
    const onStorage = (e: StorageEvent) => {
      if (e.key === STORAGE_KEY) onChange();
    };
    window.addEventListener("storage", onStorage);
    return () => {
      window.removeEventListener(STORAGE_EVENT, onChange);
      window.removeEventListener("storage", onStorage);
    };
  }, []);

  const patch = useCallback((p) => {
    const next = { ...readStorage(), ...p };
    writeStorage(next);
    window.dispatchEvent(new CustomEvent(STORAGE_EVENT));
  }, []);

  return [settings, patch] as const;
}
```

The custom event signals to OTHER React subscribers in the same tab; the `storage` event handles cross-tab. Both are needed.

## Gotchas

- **Don't put booleans in URL when they default to true.** `?pip=on` is a sentinel; `?pip=off` is the explicit disable. Defaulting on with no token is cleaner than `?pip=true` everywhere.
- **Don't put fully-arbitrary strings in URL.** Symbols and granularities come from a known set. Validating on parse and falling back to defaults keeps URL handlers tight.
- **Don't read localStorage during SSR.** Wrap reads in `typeof window === "undefined" ? defaults : readStorage()`.
- **Don't forget the `storage` event for multi-tab.** Operators open multiple tabs of the same app; expect them to be in sync.
- **Don't try to put EVERYTHING in URL "just in case."** Operator-preference state in URLs results in ugly long URLs and the share-vs-personal split gets muddy.
- **Version your storage keys.** `myapp.settings.v1`. When you change the shape, increment to v2 with a coercion function that handles the v1 → v2 migration; don't break old users.

## Reference implementation

A real example pattern: in a charting app, the picture-in-picture mini chart was originally URL-based (`?pip=on,4hour`). Watchlist navigation dropped the query string and the PiP vanished. Migrating to localStorage (key `pipSettings.v1`) with the custom-event hook above fixed the navigation issue and was a net code-reduction (the URL codec was deleted). Toolbar favorites (pinned timeframes) and PiP geometry use the same pattern; chart content (symbol, granularity, vwap config) stays in the URL.

## Related skills

- `outcome-shaped-specs` — the decision goes in the spec's Decisions section with a one-line Why
- `lightweight-charts-integration` — chart indicators (in the URL) frequently mount across the same canvas; coordinate via the URL codec

