Skip to content
react-global-state-hooks
Interactive example · Persistence

Preferences that stay

Save a preference, restore it, and keep session-only values out of storage. Validate what comes back and hydrate without a mismatch.

Try it hereLive example
Application preview

Loading the example…

store.ts
import { createGlobalState } from 'react-global-state-hooks';
import { z } from 'zod';

export const ACCENTS = ['mint', 'sky', 'yellow'] as const;
export const SIZES = ['small', 'medium', 'large'] as const;

// The shape of the whole state, saved fields and the rest alike.
const schema = z.object({
  accent: z.enum(ACCENTS),
  size: z.enum(SIZES),
  compact: z.boolean(),
  draft: z.string(),
});

export type Preferences = z.infer<typeof schema>;

export const defaults: Preferences = { accent: 'mint', size: 'medium', compact: false, draft: '' };

export const STORAGE_KEY = 'examples:preferences';

export const usePreferences = createGlobalState(() => ({ ...defaults }), {
  name: 'persistedPreferences',
  localStorage: {
    key: STORAGE_KEY,

    // save only what should survive a reload
    selector: (state) => ({ accent: state.accent, size: state.size, compact: state.compact }),

    // storage only carries the saved fields, so validate the state they produce, not the fragment
    validator: ({ restored, initial }) => schema.parse({ ...initial, ...(restored as Partial<Preferences>) }),
  },
});
Ready. Make a change to begin.Reset returns this example to its initial state.
Take it with you

Persist deliberately. Validate what returns.

01

Choose an accent and a size. The box under Saved in localStorage shows what was written.

02

The draft note appears in the preview but never in the saved data. The `selector` decides what is stored.

03

Reload the page. Your choices come back; the draft does not. Reset rewrites the defaults.

Read the full guide

The code

selector picks what is saved. validator runs on every restore and hands the stored value to a Zod schema, so an old or edited value cannot reach the page: a parse error is caught by the store, which keeps its defaults. The store uses its own namespaced key, examples:preferences; nothing else in your browser storage is touched.

store.ts
import { createGlobalState } from 'react-global-state-hooks';
import { z } from 'zod';

export const ACCENTS = ['mint', 'sky', 'yellow'] as const;
export const SIZES = ['small', 'medium', 'large'] as const;

// The shape of the whole state, saved fields and the rest alike.
const schema = z.object({
  accent: z.enum(ACCENTS),
  size: z.enum(SIZES),
  compact: z.boolean(),
  draft: z.string(),
});

export type Preferences = z.infer<typeof schema>;

export const defaults: Preferences = { accent: 'mint', size: 'medium', compact: false, draft: '' };

export const STORAGE_KEY = 'examples:preferences';

export const usePreferences = createGlobalState(() => ({ ...defaults }), {
  name: 'persistedPreferences',
  localStorage: {
    key: STORAGE_KEY,

    // save only what should survive a reload
    selector: (state) => ({ accent: state.accent, size: state.size, compact: state.compact }),

    // storage only carries the saved fields, so validate the state they produce, not the fragment
    validator: ({ restored, initial }) => schema.parse({ ...initial, ...(restored as Partial<Preferences>) }),
  },
});
PreferencesDemo.tsx
import { useEffect, useId, useState } from 'react';
import '../shared/demo.css';
import './persistence.css';
import { RenderCount } from '../shared/RenderCount';
import { useHydrated } from '../../state/useHydrated';
import { ACCENTS, SIZES, STORAGE_KEY, defaults, usePreferences, type Preferences } from './store';

function Choice<T extends string>({
  legend,
  options,
  value,
  onChange,
}: {
  legend: string;
  options: readonly T[];
  value: T;
  onChange: (value: T) => void;
}) {
  const group = useId();

  return (
    <fieldset className="segmented">
      <legend>{legend}</legend>
      {options.map((option) => (
        <label key={option}>
          <input type="radio" name={group} checked={value === option} onChange={() => onChange(option)} />
          <span>{option}</span>
        </label>
      ))}
    </fieldset>
  );
}

type Storage = { saved: string; available: boolean };

function readSaved(): Storage {
  try {
    return { saved: window.localStorage.getItem(STORAGE_KEY) ?? '(nothing saved)', available: true };
  } catch {
    return { saved: '(storage unavailable)', available: false };
  }
}

/** Writes the defaults back, which also rewrites the saved value. */
export const resetPreferencesDemo = () => usePreferences.setState({ ...defaults });

/** One-line trace for the workbench status bar. */
export const watchPreferencesDemo = (log: (line: string) => void) =>
  usePreferences.subscribe(
    (state) => log(`save → accent: ${state.accent} · size: ${state.size} · compact: ${state.compact}; draft excluded`),
    { skipFirst: true },
  );

export function PreferencesDemo() {
  const [stored, setPreferences] = usePreferences();
  // The store restores from localStorage when this module loads in the browser, before React hydrates.
  // Show the defaults until hydration is done so the page matches the server HTML, then the saved values.
  const hydrated = useHydrated();
  const preferences: Preferences = hydrated ? stored : defaults;

  const [storage, setStorage] = useState<Storage>({ saved: '', available: true });
  useEffect(() => {
    setStorage(readSaved());

    // Subscribers run before the store writes to localStorage, so read the saved value a moment later.
    return usePreferences.subscribe(() => queueMicrotask(() => setStorage(readSaved())), { skipFirst: true });
  }, []);

  const update = (patch: Partial<Preferences>) => setPreferences((current) => ({ ...current, ...patch }));

  return (
    <div className="demo">
      <div className="demo-grid demo-grid--stack">
        <section className="demo-card" aria-label="Preferences">
          <RenderCount />
          <div className="pref-row">
            <div>
              <strong>Accent</strong>
              <p>Saved. Only this preview changes.</p>
            </div>
            <Choice legend="Accent" options={ACCENTS} value={preferences.accent} onChange={(accent) => update({ accent })} />
          </div>
          <div className="pref-row">
            <div>
              <strong>Text size</strong>
              <p>Saved with the accent.</p>
            </div>
            <Choice legend="Text size" options={SIZES} value={preferences.size} onChange={(size) => update({ size })} />
          </div>
          <label className="pref-check">
            <span>Compact layout</span>
            <input type="checkbox" checked={preferences.compact} onChange={() => update({ compact: !preferences.compact })} />
          </label>
          <label className="pref-draft">
            Draft note (not saved)
            <input value={preferences.draft} placeholder="This will not be saved" maxLength={60} onChange={(event) => update({ draft: event.target.value })} />
          </label>
        </section>

        <section className="demo-card" aria-label="Preview">
          <div
            className={`pref-preview pref-preview--${preferences.accent} pref-preview--${preferences.size}${preferences.compact ? ' pref-preview--compact' : ''}`}
            data-testid="preview"
          >
            <strong>Preview</strong>
            <p>{preferences.draft || 'Your draft appears here.'}</p>
          </div>
          <span>Saved in localStorage</span>
          <pre className="demo-json" data-testid="saved">
            {storage.saved}
          </pre>
          <p className={`pref-status${storage.available ? '' : ' pref-status--unavailable'}`} role="status">
            {storage.available
              ? 'Reload this page: the saved values come back and the draft does not.'
              : 'Storage unavailable — changes are session-only'}
          </p>
        </section>
      </div>
    </div>
  );
}

Related documentation

Keep exploring

Another piece of the model.

↑↓ Navigate ↵ Open Esc CloseRGSH docs
Get started

Shared state. Precise subscriptions.
Built-in DevTools.