preferences

Keep trader and desk settings in versioned JSON slots, with boundaries for desk templates, personal settings, and session values.

import { useState } from "react"
import { createPreferences, diffPreferences, readSlot, setSlot } from "@/lib/preferences"

const baseline = setSlot(createPreferences(), "threshold", 5_000_000)

export default function PreferencesDemo() {
  const [prefs, setPrefs] = useState(baseline)
  const threshold = readSlot<number>(prefs, "threshold") ?? 0
  const diff = diffPreferences(baseline, prefs)

  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => setSlot(current, "threshold", 10_000_000))}>
          Set threshold to 10mm
        </button>
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
          Restore baseline
        </button>
      </div>
      <div className="w-72 max-w-full space-y-2 text-sm lining-nums tabular-nums">
        <p>Threshold: {threshold.toLocaleString("en-US")}</p>
        <p role="status" className="text-muted-foreground">
          {diff.changed.length ? `Changed: ${diff.changed.join(", ")}` : "Matches the baseline."}
        </p>
      </div>
    </>
  )
}

Installation

pnpm dlx shadcn@latest add tradecn/ui/preferences#v2.0.0
npx shadcn@latest add tradecn/ui/preferences#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/preferences#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/preferences#v2.0.0

Usage

import { useState } from "react"
import { createPreferences, diffPreferences, readSlot, setSlot } from "@/lib/preferences"

const baseline = setSlot(createPreferences(), "threshold", 5_000_000)

export default function ThresholdPreference() {
  const [prefs, setPrefs] = useState(baseline)
  const threshold = readSlot<number>(prefs, "threshold") ?? 0
  const diff = diffPreferences(baseline, prefs)

  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => setSlot(current, "threshold", 10_000_000))}>
          Set threshold to 10mm
        </button>
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
          Restore baseline
        </button>
      </div>
      <div className="w-72 max-w-full space-y-2 text-sm lining-nums tabular-nums">
        <p>Threshold: {threshold.toLocaleString("en-US")}</p>
        <p role="status" className="text-muted-foreground">
          {diff.changed.length ? `Changed: ${diff.changed.join(", ")}` : "Matches the baseline."}
        </p>
      </div>
    </>
  )
}

setSlot returns an updated envelope; readSlot reads the value you pass to your application. This example keeps a numeric threshold and compares it with an in-memory baseline. An unlisted slot belongs to the user boundary. No storage is written.

For persistence, serialize the envelope with JSON.stringify and read it back with parsePreferences. Your application owns the storage calls and save timing. A parsed envelope still needs each setting's domain validation before use; the numeric slot above is created entirely within the example.

Export boundaries

A desk template carries shared settings. A personal export includes those and the user's settings; both omit session values. This example keeps one slot per boundary, with each slot's version visible. Switch the export boundary, hide quantity in the blotter, or raise the session threshold and inspect the JSON. Changing the threshold leaves either export unchanged.

The JSON retains all boundary lists, so threshold still appears as a name under boundaries.session. Its value is absent from slots.

import { useState } from "react"
import { boundaryOf, createPreferences, exportableSlots, exportPreferences, readSlot, setSlot } from "@/lib/preferences"

const columns = {
  order: ["symbol", "qty", "account"],
  widths: {},
  hidden: ["account"],
}
let baseline = createPreferences({
  template: ["columns:blotter"],
  user: ["hotkeys"],
  session: ["threshold"],
})
baseline = setSlot(baseline, "columns:blotter", columns)
baseline = setSlot(baseline, "hotkeys", { "rfq.send": "mod+shift+enter" })
baseline = setSlot(baseline, "threshold", 5_000_000)

export default function PreferencesBoundariesDemo() {
  const [prefs, setPrefs] = useState(baseline)
  const [boundary, setBoundary] = useState<"template" | "user">("template")
  const threshold = readSlot<number>(prefs, "threshold") ?? 0

  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
        <button
          type="button"
          className="rounded border px-2 py-1"
          onClick={() =>
            setPrefs((current) =>
              setSlot(current, "columns:blotter", {
                ...columns,
                hidden: ["account", "qty"],
              }),
            )
          }
        >
          Hide quantity
        </button>
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => setSlot(current, "threshold", 10_000_000))}>
          Raise session threshold
        </button>
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
          Restore settings
        </button>
      </div>
      <div className="w-96 max-w-full space-y-3 text-xs lining-nums tabular-nums">
        <table className="w-full text-left">
          <thead className="text-muted-foreground">
            <tr>
              <th scope="col" className="py-1 font-medium">
                Slot
              </th>
              <th scope="col" className="font-medium">
                Boundary
              </th>
              <th scope="col" className="text-right font-medium">
                Version
              </th>
            </tr>
          </thead>
          <tbody>
            {Object.entries(prefs.slots).map(([name, slot]) => (
              <tr key={name} className="border-t border-border">
                <th scope="row" className="py-1 font-normal">
                  {name}
                </th>
                <td>{boundaryOf(prefs, name)}</td>
                <td className="text-right">{slot.version}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <p>Session threshold: {threshold.toLocaleString("en-US")}</p>
        <label className="flex flex-wrap items-center gap-2">
          Export boundary
          <select className="rounded border border-border bg-background p-1" value={boundary} onChange={(event) => setBoundary(event.target.value === "template" ? "template" : "user")}>
            <option value="template">Desk template</option>
            <option value="user">Personal settings</option>
          </select>
        </label>
        <p role="status" className="text-muted-foreground">
          Exported slots: {exportableSlots(prefs, boundary).join(", ")}
        </p>
        <pre role="region" aria-label="Exported preferences" tabIndex={0} className="max-h-64 overflow-auto rounded-md border border-border bg-card p-3 font-(family-name:--tradecn-font-mono)">
          {exportPreferences(prefs, { boundary })}
        </pre>
      </div>
    </>
  )
}

Import settings

Import merges allowed slots into the receiving desk. The sample personal export hides account and quantity columns and changes the send shortcut. Import template applies only the columns; Import personal settings applies the columns and shortcut. The receiving desk keeps its session threshold of 1,000,000 in both cases.

Restore the receiving desk between imports to compare them. Importing a template after personal settings keeps the already-imported shortcut: slots outside that transfer stay as they are. In an application, pass the JSON text from a file or clipboard to importPreferences; an invalid envelope returns null, leaving the current state intact in this example's handler.

import { useState } from "react"
import { createPreferences, diffPreferences, exportPreferences, importPreferences, setSlot } from "@/lib/preferences"

function desk(hidden: string[], shortcut: string, threshold: number) {
  let prefs = createPreferences({
    template: ["columns:blotter"],
    user: ["hotkeys"],
    session: ["threshold"],
  })
  prefs = setSlot(prefs, "columns:blotter", {
    order: ["symbol", "qty", "account"],
    widths: {},
    hidden,
  })
  prefs = setSlot(prefs, "hotkeys", { "rfq.send": shortcut })
  return setSlot(prefs, "threshold", threshold)
}

const incoming = exportPreferences(desk(["account", "qty"], "mod+shift+enter", 5_000_000))
const baseline = desk([], "mod+enter", 1_000_000)

export default function PreferencesImportDemo() {
  const [prefs, setPrefs] = useState(baseline)
  const diff = diffPreferences(baseline, prefs)

  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
        <button
          type="button"
          className="rounded border px-2 py-1"
          onClick={() =>
            setPrefs(
              (current) =>
                importPreferences(current, incoming, {
                  boundary: "template",
                }) ?? current,
            )
          }
        >
          Import template
        </button>
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => importPreferences(current, incoming) ?? current)}>
          Import personal settings
        </button>
        <button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
          Restore receiving desk
        </button>
      </div>
      <div className="w-96 max-w-full space-y-3 text-xs lining-nums tabular-nums">
        <p role="status" className="text-muted-foreground">
          {diff.changed.length ? `Changed: ${diff.changed.join(", ")}` : "Receiving desk unchanged."}
        </p>
        <pre role="region" aria-label="Receiving desk slots" tabIndex={0} className="max-h-80 overflow-auto rounded-md border border-border bg-card p-3 font-(family-name:--tradecn-font-mono)">
          {JSON.stringify(prefs.slots, null, 2)}
        </pre>
      </div>
    </>
  )
}

API Reference

The envelope

Preferences is { tradecn: "preferences", version: 1, slots, boundaries }. The marker and envelope version are exported as PREFERENCES_MARK and PREFERENCES_VERSION.

slots is a Record<string, PreferenceSlot>. Each slot has its own numeric version and a value of type PreferencesJson: a JSON primitive, array, or object. The item that owns the value defines its shape and version, separately from the envelope version.

In the functions below, prefs, target, a, and b are Preferences. A slot name is any string, including constructor and __proto__.

Function Inputs / defaults Result
createPreferences(boundaries?) Partial PreferenceBoundaries; omitted lists default to [] Empty envelope with copied boundary lists.
parsePreferences(value) unknown; object or JSON text A checked copy, or null for an invalid envelope.
getSlot(prefs, name) Slot name Stored PreferenceSlot, or undefined.
setSlot(prefs, name, value, version?) value: unknown; version is a number or the PreferenceMigrator you read with, and defaults to the existing slot's version, then 1 Envelope with a JSON copy of the value. Pass the migrator when the value came through readSlot with it: the value is at the migrator's version, and written under the slot's older one it would be migrated again on the next read.
removeSlot(prefs, name) Slot name Envelope without that slot; leaves boundary lists intact.
diffPreferences(a, b) Before and after envelopes PreferencesDiff: sorted name lists in added, removed, changed, and same.
toJson(value) unknown JSON round-trip copy, or null when serialization fails.

parsePreferences requires the marker, envelope version 1, and an object for slots. It drops malformed slots individually: each needs a nonnegative integer version and a serializable value. A non-null value that serializes to null, such as NaN, is also dropped; literal null is valid. Missing or malformed boundary lists become empty lists; nonstring entries are removed.

JSON serialization removes functions and undefined from objects, turns them into null in arrays, and converts dates to strings. setSlot ignores a value that cannot be serialized or becomes null, unless the input is literal null. It returns the same envelope when the version and serialized value match; otherwise it returns a new one. removeSlot returns the same envelope when the slot is absent.

diffPreferences compares slot versions and serialized values, ignoring boundaries. Both diffing and setSlot compare JSON.stringify output, so object key order matters. Use the diff for a support view or save prompt; identity checks can skip unchanged saves.

Boundaries

Like workspace's layout, the envelope records its persistence boundaries in the payload. PreferenceBoundary names the three categories below; PreferenceBoundaries maps each to a readonly list of slot names.

Boundary Purpose
"template" Shared desk settings; keep personal and session data out.
"user" Personal settings that follow a trader between desks.
"session" Never exported. Import skips slots the incoming envelope marks session, but can replace and reclassify a receiving desk's own session slot: boundaries are read from the envelope.

Name each slot under one boundary. An unlisted slot uses DEFAULT_BOUNDARY, "user". If lists overlap, the first match in PREFERENCE_BOUNDARIES wins: template, then user, then session.

Function Result
boundaryOf(prefs, name) Effective PreferenceBoundary for the slot name, even if no slot exists.
withBoundary(prefs, name, boundary) Same envelope if the requested boundary is already effective and explicitly lists the name; otherwise a new envelope with the name removed from every list and added to the requested one.

Export and import

The transfer boundary accepts "template" or "user", defaulting to "user":

Slot's effective boundary Template transfer User transfer
"template" Included Included
"user" or unlisted Excluded Included
"session" Excluded Excluded
Function Inputs / defaults Result
exportableSlots(prefs, boundary?) Transfer boundary Names of existing slots allowed by the table.
exportPreferences(prefs, options?) boundary; numeric indent defaults to 2 JSON text containing allowed slots and all boundary lists. Use indent: 0 for compact text.
importPreferences(target, incoming, options?) incoming: unknown, object or JSON text; boundary Merged envelope, or null when parsing fails.

Export filters slot values, not the boundary lists: excluded slots' names can still appear in the JSON. Import checks the incoming envelope's boundaries. Each allowed slot replaces the target's same-named slot and takes the incoming boundary, even if the target classified it as session. Other target slots and their boundaries stay intact.

Import returns the original target when no allowed values, versions, or boundary assignments change; importing a slot the target does not list yet still returns a new object, since it adds the listing. Parsing and importing do not migrate slot versions; use a migrator when reading or updating older values.

Versions and migrators

Supply a PreferenceMigrator when a slot's shape changes:

Field Type Purpose
version number Required target version your code reads.
migrate (value: PreferencesJson, from: number) => PreferencesJson | null Required function that converts an older value directly to the target version; null rejects it.

PreferenceMigrators is a record of slot names to migrators. migratePreferences(prefs, migrators) returns an updated envelope; readSlot<V>(prefs, name, migrator?) returns one value without writing the result back.

Stored slot migratePreferences readSlot
Missing Leave absent undefined
No migrator Keep slot Stored value, regardless of version
Older than migrator Pass migrated value to setSlot at target version; remove slot on null Migrated value, or undefined on null
Same version Keep slot Stored value
Newer than migrator Keep slot undefined

migratePreferences applies setSlot's JSON conversion and returns the same envelope when nothing changes. readSlot returns a non-null migrator result directly. Migrators run only for older slots; thrown errors propagate.

readSlot returns V | undefined, with V defaulting to PreferencesJson; the generic supplies a TypeScript type, not runtime validation.

The slots are the items' own shapes

Envelope parsing checks JSON structure, not the value's domain schema. Use the owning item's parser, such as parseWorkspaceLayout, or readRules for grid rules, before passing a slot value to a controlled prop.

Setting Value shape
Workspace layout WorkspaceLayout
Grid columns ColumnState
Hotkey overrides HotkeyOverrides
Grid rules GridRules
Threshold number

Use a separate slot name for each controlled value, such as columns:blotter and columns:stack for two grids. The boundary example needs only one grid, hotkey overrides, and a threshold to show the three categories.

What it does not do

These functions do not subscribe, notify, or persist. Your application owns storage and save timing, whether it uses localStorage, a desktop file, or a server. Keep settings in your own state and wire readSlot and setSlot to each item's controlled props.

This page is docs/preferences.md at v2.0.0.