| name | localstorage-vs-url-state |
| description | 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. |
| metadata | {"category":"state-management"} |
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:
- 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.
- 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:
- Move state to localStorage — operator preferences don't belong in URLs anyway.
- 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):
"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);
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