Build CoreOrdered learning track

Session and Persistent Client State

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 053

A deep implementation guide for session and persistent client state in React: localStorage, sessionStorage, IndexedDB boundaries, hydration, versioning, privacy, multi-tab sync, and failure modes.

9 min read1662 words
PrevNext
Lesson 53123 lesson track24–67 Build Core
#react#hooks#state-management#localstorage+5 more

Part 053 — Session and Persistent Client State

Persistent client state adalah state yang tetap ada setelah render selesai, component unmount, tab reload, atau browser dibuka kembali.

Tapi kalimat itu mudah menipu.

Bukan semua state yang ingin “tidak hilang” layak disimpan di browser. Browser storage bukan database domain. Browser storage bukan authorization boundary. Browser storage bukan server truth. Browser storage juga bukan React state.

Persistent client state adalah cache lokal untuk preferensi, draft, dan pengalaman pengguna, dengan lifecycle yang harus eksplisit.

Part ini membahas cara mendesain persistent state seperti engineer production: apa yang boleh disimpan, di mana disimpan, kapan dibaca, kapan ditulis, bagaimana migrasi schema, bagaimana sync antar tab, bagaimana menghindari hydration mismatch, dan kapan persistence justru harus dihapus.


1. Mental Model

Persistent state has two layers:

React layer     : current render snapshot used to paint UI
Storage layer   : durable serialized value outside React

The dangerous mistake is treating them as one thing.

React state is live, typed by your code, and participates in render scheduling.

Browser storage is serialized, mutable outside React, possibly stale, possibly corrupt, and possibly unavailable depending on environment and browser policy.

A robust design always has a synchronization boundary.


2. Storage Is Not Ownership

When you put a value into localStorage, you have not answered the real architectural question.

You have only answered:

Where is a serialized copy stored?

You still must answer:

Who owns this value?
Who may mutate it?
Is it canonical or only a cache?
What invalidates it?
How long may it live?
What version is its schema?
What happens if parsing fails?
What happens in another tab?
What happens during SSR/hydration?

Without those answers, persistent state becomes fossilized state: old values quietly controlling new UI.


3. Storage Option Matrix

StorageLifetimeAccessible from JSSync/AsyncBest forAvoid for
React memoryComponent/app lifetimeyesrender-managedephemeral UI, current screen statereload survival
sessionStorageper tab sessionyessynchronoustab-scoped draft, temporary wizard statecross-tab state, large data
localStorageacross browser sessionsyessynchronoussmall preferences, dismissed banners, lightweight draftslarge payloads, secrets, high-frequency writes
IndexedDBpersistent, origin-scopedyesasynchronouslarger structured data, offline queues, local documentssimple flags if Web Storage is enough
Cookierequest-bound depending attributesmaybe, unless HttpOnlybrowser-managedserver-readable session metadata, server routing hintsarbitrary UI blobs
Server DBdurable domain truthno direct JS accessnetworkcanonical user/domain datatransient UI gestures

A simple rule:

Small preference? localStorage.
Tab-only draft? sessionStorage.
Large/offline structured data? IndexedDB.
Canonical business state? server.
Secret credential? do not store in JS-accessible storage.

4. The Most Important Distinction: Persistence vs Canonical Truth

Persistent client state can be:

canonical client preference
cache of server data
draft not yet committed
session-local workflow state
feature behavior hint

Only the first category is often safe as client-owned truth.

Examples of safe-ish client-owned truth:

theme preference
table density
sidebar collapsed state
last selected local tab
editor layout preference

Examples that are usually not client-owned truth:

current account role
permissions
billing status
case enforcement status
approval result
server mutation result

The browser may remember that a button was collapsed. It must not become the authority that a user is an admin.


5. Good Persistent State Candidates

5.1 UI Preferences

Good candidates:

theme: light | dark | system
density: compact | comfortable
sidebarCollapsed: boolean
tableColumnOrder: string[]

Properties:

small
non-sensitive
user-owned
low-frequency updates
safe to default if missing

5.2 Draft State

Good candidates:

unsent comment draft
multi-step wizard draft
local note draft
search filter draft before commit

But draft state needs explicit retention.

Should it survive tab close?
Should it expire after 24 hours?
Should it be cleared after submit?
Should it be scoped by user id, tenant id, resource id, or route?

A draft key like this is dangerous:

draft:comment

A safer key is scoped:

app:v2:tenant:{tenantId}:user:{userId}:case:{caseId}:comment-draft

5.3 Dismissed UI

Good candidates:

dismissed onboarding banner
dismissed release note
dismissed migration warning

But the key must include the semantic version of the content:

dismissed:release-note:2026-07-state-redesign

Not:

dismissedReleaseNote

5.4 Offline Queue

This is possible, but it is no longer a simple persistent state problem.

An offline queue needs:

idempotency key
retry policy
conflict resolution
visibility to the user
encryption/privacy policy if sensitive
server reconciliation

For serious offline workflows, treat IndexedDB + service worker + server idempotency as a subsystem, not a hook snippet.


6. Dangerous Persistent State Candidates

6.1 Permissions and Roles

Do not use JS-accessible persistent state as authority for permissions.

Bad:

const isAdmin = localStorage.getItem('role') === 'admin';

Slightly less bad but still not authoritative:

const cachedCapabilities = readCachedCapabilities();

Better mental model:

Client permission state may improve UX.
Server permission checks enforce truth.

The UI may hide a button optimistically, but the server must reject unauthorized commands.

6.2 Tokens and Secrets

Avoid storing sensitive credentials in localStorage or sessionStorage when possible because any script running in the origin can read JS-accessible storage. If an attacker obtains script execution through XSS, those values are exposed.

This is not a React issue. It is a browser security boundary issue.

6.3 High-Frequency State

Bad candidates:

mouse position
scroll position updated every frame
input value persisted on every keystroke without debounce
animation state
large editor document on every character

Web Storage APIs are synchronous. Heavy read/write operations can block the main thread.

Use batching, debounce, idle callback, or IndexedDB depending on payload and frequency.


7. Naming Keys Like an Engineer

A storage key is a public internal API.

Treat it like a schema contract.

Bad:

filters
settings
draft
user

Better:

acme-app:v3:user:{userId}:table:{tableId}:columns
acme-app:v2:tenant:{tenantId}:case:{caseId}:comment-draft
acme-app:v1:ui:theme

Key design dimensions:

DimensionQuestion
App namespaceCould another app on same origin collide?
Schema versionCan old values be migrated or ignored?
User scopeWill user A see user B's stored data on shared device?
Tenant scopeDoes tenant context change meaning?
Resource scopeIs draft tied to case/order/project id?
Feature scopeWhich subsystem owns this value?

A storage key should tell a future engineer what state it owns.


8. The Naive Hook

You will see this everywhere:

function useLocalStorageState<T>(key: string, initialValue: T) {
  const [value, setValue] = React.useState<T>(() => {
    const raw = window.localStorage.getItem(key);
    return raw == null ? initialValue : JSON.parse(raw);
  });

  React.useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

It works for a demo.

In production, it has issues:

breaks on server because window is unavailable
throws if JSON is corrupt
has no schema validation
has no migration
writes after every value change
has no expiration
has no cross-tab synchronization
can hydrate differently from server markup
does not handle storage quota/security errors

Do not start and end here.


9. Safer Persistent State Envelope

Store an envelope, not a naked value.

type PersistedEnvelope<T> = {
  version: number;
  createdAt: string;
  updatedAt: string;
  expiresAt?: string;
  value: T;
};

Why?

Because storage is long-lived. Your code is not.

You need metadata to answer:

Can this value still be trusted?
Can this schema still be read?
Should this value be deleted?
When was it last written?

Example read function:

type DecodeResult<T> =
  | { ok: true; value: T }
  | { ok: false; reason: 'missing' | 'expired' | 'invalid-json' | 'invalid-schema' | 'unsupported-version' };

function decodeEnvelope<T>(
  raw: string | null,
  options: {
    version: number;
    validate: (value: unknown) => value is T;
    migrate?: (envelope: PersistedEnvelope<unknown>) => PersistedEnvelope<T> | null;
    now?: () => Date;
  }
): DecodeResult<T> {
  if (raw == null) return { ok: false, reason: 'missing' };

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return { ok: false, reason: 'invalid-json' };
  }

  if (!isEnvelope(parsed)) {
    return { ok: false, reason: 'invalid-schema' };
  }

  const envelope = parsed as PersistedEnvelope<unknown>;
  const now = options.now?.() ?? new Date();

  if (envelope.expiresAt && new Date(envelope.expiresAt) <= now) {
    return { ok: false, reason: 'expired' };
  }

  if (envelope.version !== options.version) {
    const migrated = options.migrate?.(envelope);
    if (!migrated) return { ok: false, reason: 'unsupported-version' };
    if (!options.validate(migrated.value)) return { ok: false, reason: 'invalid-schema' };
    return { ok: true, value: migrated.value };
  }

  if (!options.validate(envelope.value)) {
    return { ok: false, reason: 'invalid-schema' };
  }

  return { ok: true, value: envelope.value };
}

function isEnvelope(value: unknown): value is PersistedEnvelope<unknown> {
  if (typeof value !== 'object' || value === null) return false;
  const record = value as Record<string, unknown>;
  return (
    typeof record.version === 'number' &&
    typeof record.createdAt === 'string' &&
    typeof record.updatedAt === 'string' &&
    'value' in record
  );
}

This looks boring. That is the point.

Persistent state should be boring because the cost of corruption is paid later by users.


10. A Production-Grade usePersistentState

This hook handles:

SSR-safe initialization
parse failure
schema validation
version envelope
debounced persistence
storage write errors
reset
type PersistentStateOptions<T> = {
  key: string;
  version: number;
  initialValue: T | (() => T);
  validate: (value: unknown) => value is T;
  storage?: Storage;
  debounceMs?: number;
  expiresInMs?: number;
  onDecodeError?: (reason: string) => void;
  onWriteError?: (error: unknown) => void;
};

function resolveInitial<T>(value: T | (() => T)): T {
  return typeof value === 'function' ? (value as () => T)() : value;
}

function createEnvelope<T>(
  value: T,
  version: number,
  expiresInMs?: number
): PersistedEnvelope<T> {
  const now = new Date();
  return {
    version,
    createdAt: now.toISOString(),
    updatedAt: now.toISOString(),
    expiresAt: expiresInMs ? new Date(now.getTime() + expiresInMs).toISOString() : undefined,
    value,
  };
}

export function usePersistentState<T>(options: PersistentStateOptions<T>) {
  const {
    key,
    version,
    initialValue,
    validate,
    debounceMs = 150,
    expiresInMs,
    onDecodeError,
    onWriteError,
  } = options;

  const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : undefined);

  const [state, setState] = React.useState<T>(() => {
    const fallback = resolveInitial(initialValue);
    if (!storage) return fallback;

    const decoded = decodeEnvelope(storage.getItem(key), { version, validate });
    if (!decoded.ok) {
      if (decoded.reason !== 'missing') onDecodeError?.(decoded.reason);
      return fallback;
    }

    return decoded.value;
  });

  const reset = React.useCallback(() => {
    setState(resolveInitial(initialValue));
    try {
      storage?.removeItem(key);
    } catch (error) {
      onWriteError?.(error);
    }
  }, [initialValue, key, onWriteError, storage]);

  React.useEffect(() => {
    if (!storage) return;

    const timer = window.setTimeout(() => {
      try {
        storage.setItem(
          key,
          JSON.stringify(createEnvelope(state, version, expiresInMs))
        );
      } catch (error) {
        onWriteError?.(error);
      }
    }, debounceMs);

    return () => window.clearTimeout(timer);
  }, [debounceMs, expiresInMs, key, onWriteError, state, storage, version]);

  return [state, setState, reset] as const;
}

This is still not universal. It is a baseline.

For high-frequency, large, multi-tab, transactional, or offline data, move to a real external store + IndexedDB abstraction.


11. Hydration and SSR

The browser has localStorage. The server does not.

That sounds obvious until a component renders different HTML on server and client.

Bad pattern:

function ThemeLabel() {
  const theme = window.localStorage.getItem('theme') ?? 'light';
  return <span>{theme}</span>;
}

This can fail in SSR because window is unavailable. It can also cause mismatch if the server renders one value and the client immediately renders another.

Better patterns depend on the value.

11.1 Non-Critical Preference

Render stable fallback first, then hydrate client value.

function ThemeLabel() {
  const [theme, setTheme] = React.useState<'light' | 'dark' | null>(null);

  React.useEffect(() => {
    const raw = window.localStorage.getItem('app:v1:theme');
    setTheme(raw === 'dark' ? 'dark' : 'light');
  }, []);

  if (theme === null) return null;
  return <span>{theme}</span>;
}

This avoids server/client disagreement at the cost of delayed UI.

11.2 Critical Visual Preference

For theme, delayed UI may cause flash.

A common production pattern:

store preference in localStorage
run a tiny inline script before React loads
set html class/data-theme immediately
let React adopt the already-applied theme

This is not “React state first”. It is document bootstrapping.

11.3 External Store with Server Snapshot

For browser-backed external state, useSyncExternalStore can provide a server snapshot and a client snapshot.

function createStorageStore<T>(options: {
  key: string;
  defaultValue: T;
  parse: (raw: string | null) => T;
  serialize: (value: T) => string;
}) {
  return {
    getSnapshot() {
      return options.parse(window.localStorage.getItem(options.key));
    },
    getServerSnapshot() {
      return options.defaultValue;
    },
    subscribe(listener: () => void) {
      const onStorage = (event: StorageEvent) => {
        if (event.key === options.key) listener();
      };
      window.addEventListener('storage', onStorage);
      return () => window.removeEventListener('storage', onStorage);
    },
    set(value: T) {
      window.localStorage.setItem(options.key, options.serialize(value));
      window.dispatchEvent(new StorageEvent('storage', { key: options.key }));
    },
  };
}

Important nuance: the native storage event fires in other documents, not reliably in the same document that performed the write. If the current tab needs immediate notification, dispatch your own event or call subscribers directly.


12. Multi-Tab State

Persistent storage is shared per origin. Your UI may be open in multiple tabs.

This creates questions:

If user changes theme in tab A, should tab B update?
If user logs out in tab A, should tab B react?
If draft changes in tab A, should tab B overwrite local draft?

Different state needs different policy.

StateMulti-tab policy
Themesync immediately
Logout/session invalidationsync immediately
Table densitysync eventually
Form draftdo not blindly overwrite active tab
Offline command queuecoordinate carefully

A simple storage event listener is enough for low-risk values.

React.useEffect(() => {
  function handleStorage(event: StorageEvent) {
    if (event.key !== key) return;
    setState(readValueFromStorage());
  }

  window.addEventListener('storage', handleStorage);
  return () => window.removeEventListener('storage', handleStorage);
}, [key]);

For richer communication, consider BroadcastChannel.

const channel = new BroadcastChannel('app-state');
channel.postMessage({ type: 'theme/changed', value: 'dark' });

But do not confuse multi-tab messaging with state ownership. Messaging only tells other tabs something happened.


13. Expiration and Retention

Persistent state without retention policy is a memory leak in user space.

Drafts should expire.

Feature flags should refresh.

Dismissed warnings should be versioned.

Sensitive UI state should be cleared on logout.

Example retention policy:

DataRetention
Themeindefinite until user changes
Table columnsindefinite per user/table version
Comment draft7 days or after submit
Search filtersuntil user clears or route changes
Onboarding dismissuntil onboarding version changes
Tenant-scoped preferenceclear when tenant access removed

Do not store old state forever because it was convenient.


14. Logout and Account Switching

Browser storage is origin-scoped, not user-scoped.

If the app supports login/logout, shared devices, impersonation, tenant switching, or multiple accounts, you need explicit clearing/scoping.

Bad:

app:v1:case-search-filters

Better:

app:v1:user:{userId}:tenant:{tenantId}:case-search-filters

On logout:

function clearUserScopedStorage(userId: string) {
  const prefix = `app:v1:user:${userId}:`;

  for (let i = window.localStorage.length - 1; i >= 0; i--) {
    const key = window.localStorage.key(i);
    if (key?.startsWith(prefix)) {
      window.localStorage.removeItem(key);
    }
  }
}

Be careful with prefix deletion. Namespacing is what makes it safe.


15. Persistent State and React Effects

Persistence is one of the valid uses of effects because you are synchronizing React state with an external system.

But the effect must be clear:

React state changed -> write serialized snapshot to storage
Storage changed externally -> update React state

Do not use effects to derive persistent state from other persistent state if it can be computed during render.

Bad:

const [firstName, setFirstName] = usePersistentState('first', '');
const [lastName, setLastName] = usePersistentState('last', '');
const [fullName, setFullName] = usePersistentState('full', '');

React.useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

Better:

const fullName = `${firstName} ${lastName}`;

Persistence does not justify duplicated state.


16. Patterns

16.1 Persisted Theme

type Theme = 'light' | 'dark' | 'system';

function isTheme(value: unknown): value is Theme {
  return value === 'light' || value === 'dark' || value === 'system';
}

function useThemePreference() {
  return usePersistentState<Theme>({
    key: 'learn-react:v1:ui:theme',
    version: 1,
    initialValue: 'system',
    validate: isTheme,
  });
}

16.2 Persisted Table Layout

type TableLayout = {
  columnOrder: string[];
  hiddenColumns: string[];
  density: 'compact' | 'comfortable';
};

function tableLayoutKey(userId: string, tableId: string) {
  return `learn-react:v2:user:${userId}:table:${tableId}:layout`;
}

Table layout is user-scoped and table-scoped. It is not global.

16.3 Persisted Draft with Resource Scope

type CommentDraft = {
  body: string;
  attachments: Array<{ localId: string; name: string }>;
};

function commentDraftKey(userId: string, caseId: string) {
  return `learn-react:v1:user:${userId}:case:${caseId}:comment-draft`;
}

After successful submit:

resetDraft();

Do not let submitted drafts resurrect after navigation.


17. Persistent Query Cache Is Different

Server state persistence is not the same as client preference persistence.

For example, persisting a query cache needs:

query key compatibility
cache age
stale time
garbage collection
mutation invalidation
schema drift
network reconciliation

Do not manually copy API responses into localStorage unless you are intentionally building a cache subsystem.

If using a server-state library, prefer its persistence mechanism because it understands cache metadata better than a generic useLocalStorage hook.


18. Failure Modes

18.1 Fossilized State

Symptom:

A feature changed, but old users see broken UI because old storage values still load.

Cause:

No versioning, migration, or invalidation.

Fix:

Use namespaced versioned keys or envelope version migration.

18.2 Hydration Mismatch

Symptom:

Server renders default UI, client immediately renders stored value, React warns or flickers.

Fix:

Use stable server fallback, client-only hydration, cookie/bootstrap script, or useSyncExternalStore server snapshot.

18.3 Storage as Permission Authority

Symptom:

User can modify storage and unlock UI behavior.

Fix:

Treat client permission state as UX hint only. Enforce on server.

18.4 Cross-User Leakage

Symptom:

User B sees User A's filters, drafts, or layout on shared device.

Fix:

Scope keys by user/tenant or clear on logout.

18.5 Main Thread Blocking

Symptom:

Typing becomes janky after adding persistence.

Cause:

Large synchronous JSON serialization on every keystroke.

Fix:

Debounce, batch, move large data to IndexedDB, avoid high-frequency writes.

18.6 Corrupt JSON Crash

Symptom:

App crashes on startup for one user.

Cause:

JSON.parse throws during lazy initializer.

Fix:

Safe decode with fallback and storage cleanup.

18.7 Multi-Tab Lost Update

Symptom:

Two tabs edit same draft and one silently overwrites the other.

Fix:

Add updatedAt, conflict detection, active-tab warning, or avoid cross-tab draft sharing.

19. Testing Persistent State

Test these behaviors:

missing value uses fallback
valid value loads
invalid JSON falls back
invalid schema falls back
expired value falls back and optionally clears
version migration works
write error does not crash UI
reset removes storage
logout clears user-scoped keys
storage event updates subscriber
same-tab update notifies local subscriber

Example unit test shape:

it('falls back when storage contains invalid json', () => {
  localStorage.setItem('app:v1:theme', '{');

  const result = decodeEnvelope(localStorage.getItem('app:v1:theme'), {
    version: 1,
    validate: isTheme,
  });

  expect(result).toEqual({ ok: false, reason: 'invalid-json' });
});

Testing persistence is not optional. Bugs often appear only after weeks of real user storage history.


20. Decision Checklist

Before persisting state, answer:

Is this value safe to store in JS-accessible browser storage?
Is it client-owned, server-owned, or only a draft?
What is the retention policy?
What key namespace scopes it?
Does it need user/tenant/resource scoping?
What is the schema version?
What happens if the value is missing?
What happens if it is corrupt?
What happens if it is old?
What happens across tabs?
What happens during SSR/hydration?
What happens on logout?
What is the performance cost of serialization?

If the team cannot answer these, the state is not ready to be persisted.


21. Summary

Persistent client state is useful when it preserves user intent across time.

It is dangerous when it silently becomes domain truth.

The production-grade mental model:

React state is the render snapshot.
Browser storage is a serialized external system.
Persistence is synchronization.
Stored data needs namespace, schema, retention, validation, and invalidation.
Security-sensitive and server-owned data must not rely on JS-accessible storage as authority.

The next part moves from persistence to normalized client state: how to represent entity graphs so updates stay consistent when the same domain object appears in many places at once.

Lesson Recap

You just completed lesson 53 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.