Skip to content

State and Context Conventions

Client-side state in the Arda SPA lives in two tiers: the data-fetching layer’s cache (server-owned data, covered by the forthcoming data-fetching layer page) and React context/state (UI and session state the browser owns). This page is about the second tier — how to decide where a piece of context lives, how big it should be, and how components are allowed to touch it.

📍 Current state — why these conventions are necessary

Section titled “📍 Current state — why these conventions are necessary”

Browser state today is spread across three overlapping systems, and the same concern is frequently stored more than once.

1. Redux Toolkit + redux-persist is the primary approach: nine slices (auth, ui, items, scan, orderDraft, itemsFilterSort, orderQueue, receiving, account), each with its own hand-tuned persist whitelist. This is the current source of truth for auth, UI, and per-view state.

2. React contexts — roughly fifteen of them — with two contradictory shape conventions living side by side. The legacy ones (AuthContext, JWTContext, SidebarVisibilityContext) are fat (many concerns per context), export the raw createContext handle, and throw when read outside a provider — and are now dormant/unmounted, superseded by Redux store hooks of the same name. The newer ones (ItemCardsContext, PrintingSettingsContext, BulkPrintingContext, GridCellCallbacksContext, TourProvider) keep the handle private and expose hooks — but default to silent no-ops or degraded values instead of throwing. There is no agreed shape.

3. Direct localStorage access, scattered. There is no useLocalStorage hook; a thin src/lib/storage.ts wrapper exists but is imported only by its own test. Production code hits localStorage directly at 40+ call sites — auth tokens read inline in Authorization headers across a dozen components, plus grid state, form drafts, tour progress, and a cache-freshness marker. SSR (typeof window) guards are applied inconsistently.

The result is duplication, not just sprawl. Auth lives in Redux and localStorage (kept in sync only opportunistically by an AuthInit bridge). Sidebar visibility is stored three ways: a legacy context writing localStorage['sidebarVisibility'], the Redux ui.sidebarVisibility slice, and redux-persist’s own persist:ui key. Column/grid state overlaps between a persisted Redux slice and two direct-localStorage schemes with a migration shim between them. These conventions exist to give “where does this state go?” exactly one answer — and to converge the duplication onto a single mechanism.

The app is sunsetting Redux in favor of the context-and-hooks pattern on this page. State that lives in Redux Toolkit slices today — and in the legacy contexts above — migrates to atomic, hook-exposed providers scoped by file-path hierarchy. These conventions are the target for new state and for anything being refactored; they are deliberately narrow and mechanical so that “where does this go?” has one answer. (Auth is a special case: it moves into the data-fetching layer’s ArdaProvider.)

Four rules:

  1. Scope follows the file-path hierarchy. A provider lives at the narrowest directory that contains all of its consumers.
  2. Context is atomic. One context carries one concern, not a grab-bag of unrelated state.
  3. Providers expose hooks, never a raw context object. The createContext handle is a private implementation detail of the module that owns it.
  4. Persist through a hook, never localStorage directly. Persistent reads and writes go through useLocalStorage, never raw localStorage calls.

📁 Scope follows the file-path hierarchy

Section titled “📁 Scope follows the file-path hierarchy”

The directory a provider lives in encodes its scope. The rule is a function of who consumes it:

  • A context used only within a component and its subcomponents lives in that component’s directory. If a <Composer> component and the children under composer/ are the only consumers of some composer state, the provider belongs in composer/ — not in a shared location. Its scope is visible from its path: nothing outside composer/ can import it without reaching across a boundary, which is the signal that the scope was drawn wrong.
  • A context used across unrelated parts of the app lives in the root-level hooks/ directory. Application-wide concerns — locale, theme, the session-level things every page may read — have no single owning component, so they live at the root and are provided high in the tree.

The decision rule, applied to any new piece of context:

Find the lowest common directory of everything that consumes it. If that directory is a single component’s folder, the provider goes there. If consumers span unrelated features, it goes in root hooks/.

This keeps scope legible from the tree alone and resists the drift where a component-local concern is hoisted to a global just because a global was convenient to import. (Today the opposite is true: almost every provider is mounted globally in the single root layout.tsx, and some feature-local contexts had to be hoisted to root just to stop their silent no-op defaults from firing — file location says nothing about scope.)

PlantUML diagram

The diagram above contrasts the two placements: LocaleProvider lives in root hooks/ because pages across the app read it, while ComposerDraftProvider lives inside composer/ because only the composer subtree consumes it.

A context carries exactly one concern. Locale is one context; theme is another; the composer draft is another. Do not bundle unrelated state into a single “app context” object.

Atomic contexts matter because React re-renders every consumer of a context when any part of its value changes. A fat context that carries locale, theme, and sidebar state re-renders locale consumers when the sidebar toggles. Splitting by concern means a consumer only re-renders when its concern changes. It also keeps the file-path rule tractable — a single-concern context has a single, findable set of consumers, so its correct directory is unambiguous. (The legacy AuthContext is the counter-example: one context bundling sign-in, sign-out, password flows, refresh, and user state — see the current state.)

If two concerns genuinely change together and are always consumed together, one context is fine. The test is consumption, not conceptual relatedness.

🪝 Providers expose hooks, never a raw context object

Section titled “🪝 Providers expose hooks, never a raw context object”

The createContext handle is never exported. A context module exports a provider component and one or more hooks; the context object itself stays private. This gives the module control over the read/write surface, lets it enforce that consumers are inside a provider, and means the value shape can change without every consumer importing the raw context.

The canonical shape, using an application-wide locale context as the example:

src/hooks/Locale.tsx
import React, { createContext, useContext, useState } from "react";
const DEFAULT_LOCALE = "en-US";
// Private: the context object is never exported.
const LocaleContext = createContext<[string, (locale: string) => void]>([
DEFAULT_LOCALE,
() => {
throw new Error("useLocale used outside a LocaleProvider");
},
]);
export const LocaleProvider = ({ children }: { children: React.ReactNode }) => {
const [locale, setLocale] = useState<string>(DEFAULT_LOCALE);
return (
<LocaleContext.Provider value={[locale, setLocale]}>
{children}
</LocaleContext.Provider>
);
};
// One hook, shaped like `useState`: returns the [value, setter] tuple.
export const useLocale = (): [string, (locale: string) => void] =>
useContext(LocaleContext);

Three details in that shape are load-bearing:

  • One hook, shaped like useState. useLocale returns the [value, setter] tuple, so context state reads and writes exactly like local state: const [locale, setLocale] = useLocale(). A consumer that only reads destructures the first slot alone — const [locale] = useLocale() — which keeps its read-only intent explicit at the call site. (When a concern genuinely needs a narrower surface — a value most consumers must never write, or an expensive value whose readers should not re-render on every write — a context can instead export separate reader and writer hooks. The tuple is the default shape, not the only one; see the richer example below.)
  • The throwing default. The context’s default setter throws. Calling setLocale from a component mounted outside a LocaleProvider fails immediately with a clear message (“used outside a LocaleProvider”) instead of silently no-op-ing. The default value is a real fallback (DEFAULT_LOCALE), so a read-only consumer still renders; only an attempt to write outside the provider throws. (The newer feature contexts today default to silent no-ops — which is exactly how a BulkPrinting/GridCellCallbacks mis-mount can fail invisibly; a throwing setter surfaces the bug.)
  • The context object is module-private. Consumers import useLocale / LocaleProvider, never LocaleContext. Nothing outside the module can call useContext(LocaleContext) and bypass the hook surface. (The legacy AuthContext exports its raw handle for the mock provider — precisely the leak this rule forbids.)

Using it in a component. The provider is mounted once, at the scope the file-path rule dictates — for an app-wide concern like locale, that is high in the tree so every page sits inside it:

// src/app/layout.tsx — mounted once, above every consumer.
import { LocaleProvider } from "@/hooks/Locale";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <LocaleProvider>{children}</LocaleProvider>;
}

Consumers then reach the state only through the hook — never the context object. A read-only component destructures just the value slot, so its intent is explicit and it cannot accidentally mutate:

// src/components/LocaleBadge.tsx — reads only; destructures just the value.
import { useLocale } from "@/hooks/Locale";
export function LocaleBadge() {
const [locale] = useLocale();
return <span aria-label="Current locale">{locale}</span>;
}

A component that needs to change the value destructures both slots:

// src/components/LocaleSwitcher.tsx — reads and writes.
import { useLocale } from "@/hooks/Locale";
export function LocaleSwitcher() {
const [locale, setLocale] = useLocale();
return (
<select value={locale} onChange={(e) => setLocale(e.target.value)}>
<option value="en-US">English</option>
<option value="fr-FR">Français</option>
</select>
);
}

Two properties fall out of this that the rules above were chosen to give you: LocaleBadge destructures only the value slot, so its read-only intent is explicit at the call site; and if either component is mounted outside a LocaleProvider, calling setLocale throws immediately with a named error rather than silently no-op-ing.

For a component-local context the file simply lives in the component’s directory instead of hooks/, and the provider is mounted inside the owning component rather than in the root layout — the hook usage is identical; only the path (and therefore the scope) differs:

// src/features/composer/Composer.tsx — provider scoped to the composer subtree.
import { ComposerDraftProvider, useComposerDraft } from "./ComposerDraftContext";
export function Composer() {
return (
<ComposerDraftProvider>
<ComposerToolbar />
<ComposerEditor />
</ComposerDraftProvider>
);
}
// src/features/composer/ComposerToolbar.tsx — consumes it; nothing outside composer/ can.
function ComposerToolbar() {
const [draft, setDraft] = useComposerDraft();
return <button onClick={() => setDraft({ ...draft, pinned: !draft.pinned })}>Pin</button>;
}

When the surface is richer than value + setter

Section titled “When the surface is richer than value + setter”

Not every context is [value, setter]. When the state has operations richer than a plain set — items you add and remove, a flow you advance, a value with domain-specific mutations — the hook exposes those operations by name instead of handing back a raw setter for callers to drive correctly. The shape still follows the same three rules (one hook, private context, mutators that default to throwing); only the surface is wider:

src/features/print-queue/PrintQueueContext.tsx
import { createContext, useContext, useMemo, useState } from "react";
type PrintQueue = {
items: readonly string[];
add: (itemId: string) => void;
remove: (itemId: string) => void;
clear: () => void;
};
const notInProvider = () => {
throw new Error("usePrintQueue used outside a PrintQueueProvider");
};
// Private context; every mutator defaults to throwing.
const PrintQueueContext = createContext<PrintQueue>({
items: [],
add: notInProvider,
remove: notInProvider,
clear: notInProvider,
});
export function PrintQueueProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<readonly string[]>([]);
const value = useMemo<PrintQueue>(
() => ({
items,
add: (id) => setItems((prev) => (prev.includes(id) ? prev : [...prev, id])),
remove: (id) => setItems((prev) => prev.filter((x) => x !== id)),
clear: () => setItems([]),
}),
[items],
);
return (
<PrintQueueContext.Provider value={value}>
{children}
</PrintQueueContext.Provider>
);
}
// One hook — but its surface is domain operations, not a setter.
export const usePrintQueue = (): PrintQueue => useContext(PrintQueueContext);

A consumer names the operation it needs, and the intent reads off the call site — no component ever reconstructs the next state itself:

src/features/print-queue/PrintQueueToolbar.tsx
import { usePrintQueue } from "./PrintQueueContext";
export function PrintQueueToolbar() {
const { items, clear } = usePrintQueue();
return (
<button disabled={items.length === 0} onClick={clear}>
Clear {items.length} queued
</button>
);
}

The tuple form and this operation form are the same pattern at two widths: expose exactly the surface consumers need, keep the context private, and make the mutators throw outside their provider.

💾 Persisting state: never use localStorage directly

Section titled “💾 Persisting state: never use localStorage directly”

Some state must survive a reload — a collapsed sidebar, the active tab, a grid’s column layout on this machine. The browser primitive for that is localStorage, but components must never call it directly. (Today they do, at 40+ sites, with no shared wrapper — see the current state.)

Use localStorage sparingly. It is device-local and invisible to the backend, so it suits only ephemeral, device-scoped view state — the kind of thing a user would not expect to follow them to another machine. Anything that is genuinely a user setting — a preference the user expects to persist across devices and sessions — belongs in the API, persisted server-side, not in localStorage. A dedicated settings service will own that persistence; it has not been designed yet, so treat any setting parked in localStorage for now as a temporary home to migrate out of once the service exists, and prefer not to accumulate cross-device preferences there in the first place. When in doubt, ask “would the user be surprised if this reset on a different machine?” — if yes, it is a user setting and belongs server-side.

For the view state that legitimately does live on the device, direct localStorage access still has four problems that surface immediately in a Next.js app:

  • It breaks server rendering. localStorage (and window) do not exist during server render. A component that reads localStorage in render throws on the server, or forces a typeof window guard at every call site.
  • It is not reactive. Writing a key does not re-render the components reading it, so state and storage drift apart until the next reload.
  • It does not sync — across tabs or across components. Two tabs, or even two components in the same tab reading the same key, each keep their own copy unless you wire up the storage event and a same-tab notification channel yourself.
  • It scatters stringly-typed keys and JSON boilerplate. Every call site repeats JSON.parse / JSON.stringify inside a try/catch, and a typo in a key string fails silently.

The convention is a single useLocalStorage hook that hides all four. It has the shape of useState, so persistent state reads like ordinary state — but it is built on React’s useSyncExternalStore, which makes it SSR-safe and keeps every reader in sync as storage changes, across tabs and across hook instances in the same tab. It ships in the SPA at src/hooks/useLocalStorage.ts:

src/hooks/useLocalStorage.ts
'use client';
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
type SetValue<T> = (value: T | ((prev: T) => T)) => void;
// The native `storage` event only fires in *other* tabs, so same-tab instances
// need their own notification channel.
const localListeners = new Map<string, Set<() => void>>();
function subscribeLocal(key: string, listener: () => void): () => void {
let set = localListeners.get(key);
if (!set) {
set = new Set();
localListeners.set(key, set);
}
set.add(listener);
return () => {
set.delete(listener);
// Guard against deleting a fresh set created for this key after we unsubscribed.
if (set.size === 0 && localListeners.get(key) === set) {
localListeners.delete(key);
}
};
}
function notifyLocal(key: string): void {
localListeners.get(key)?.forEach((listener) => listener());
}
function readRaw(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function parse<T>(raw: string | null, fallback: T): T {
if (raw === null) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function subscribe(key: string, onChange: () => void): () => void {
const onStorage = (event: StorageEvent) => {
if (event.key === key && event.storageArea === window.localStorage) {
onChange();
}
};
window.addEventListener('storage', onStorage);
const unsubscribeLocal = subscribeLocal(key, onChange);
return () => {
window.removeEventListener('storage', onStorage);
unsubscribeLocal();
};
}
/**
* Persists state to `localStorage`, synced across hook instances in this tab
* and others. Built on `useSyncExternalStore`, so it is SSR-safe and re-reads
* on storage changes. A failed write (storage full/blocked) persists nothing
* and leaves the reported value unchanged.
*/
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, SetValue<T>] {
// Lazy initializer stores the value as-is (a function value is not invoked)
// and freezes it; a later different initialValue is intentionally ignored.
const [initial] = useState(() => initialValue);
const subscribeToKey = useCallback(
(onChange: () => void) => subscribe(key, onChange),
[key],
);
// Snapshot is the raw string — a primitive React can compare by value, so no
// caching is needed to keep it stable; parsing happens in the useMemo below.
const getSnapshot = useCallback(() => readRaw(key), [key]);
const getServerSnapshot = useCallback(() => null, []);
const raw = useSyncExternalStore(
subscribeToKey,
getSnapshot,
getServerSnapshot,
);
const storedValue = useMemo<T>(() => parse(raw, initial), [raw, initial]);
const setValue = useCallback<SetValue<T>>(
(value) => {
// Resolve functional updates against the persisted value, not React state,
// so concurrent instances agree.
const next =
value instanceof Function
? value(parse(readRaw(key), initial))
: value;
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch {
return;
}
notifyLocal(key);
},
[key, initial],
);
return [storedValue, setValue];
}
export default useLocalStorage;

Usage is a drop-in for useState:

const [collapsed, setCollapsed] = useLocalStorage("sidebar.collapsed", false);

Three properties of this implementation are worth calling out:

  • It is SSR-safe without a mount gate. The server snapshot is null, which parses to initialValue, so the server-rendered markup and the first hydration render agree; useSyncExternalStore then reconciles to the stored value immediately after hydration. No typeof window guard appears at any call site, and no subtree needs to be gated on mount. (A value that must be correct in the server-rendered HTML itself cannot come from localStorage — the server cannot read it — so that is server-fetched state, not device-local view state.)
  • It syncs same-tab instances, not just other tabs. The native storage event fires only in other tabs, so the hook also keeps a per-key listener map and notifies it on every write. Two components reading the same key — or a component and a preference provider built on it — stay consistent within one tab.
  • Functional updates resolve against storage, and failed writes are inert. setValue(prev => …) re-reads and parses the persisted value before applying the updater, so concurrent instances converge instead of racing on stale in-memory copies; a write that throws (storage full or blocked) persists nothing and leaves the reported value unchanged.

Two things follow from this being the only sanctioned path to storage:

  • It composes with the provider pattern. An app-wide preference that several features read — sidebar visibility, theme — is a provider in root hooks/ whose provider component holds the state via useLocalStorage and exposes reader/writer hooks. Persistence becomes an implementation detail behind the same hook surface every other context uses; consumers cannot tell a persisted context from an in-memory one.
  • It replaces redux-persist. As Redux is retired (below), the device-local slices it carried — sidebar visibility, active tab — move to useLocalStorage, either directly or behind a preference provider. The slices that are really user settings (column visibility, drafts, saved filters) get useLocalStorage only as an interim home until the settings service exists, at which point they move to the API. There is no second device-side persistence mechanism to reason about — which is what finally collapses the three-way sidebar duplication and the overlapping grid-state schemes.

🔗 Relationship to the data-fetching layer

Section titled “🔗 Relationship to the data-fetching layer”

These conventions govern browser-owned state. Server-owned data — anything the backend is the source of truth for — does not belong in a hand-rolled context; it belongs in the data-fetching layer, whose ArdaProvider and useGet / useMutation hooks already follow the same provider-plus-hooks shape. Before adding a context, ask whether the state is really server data being cached; if so, use the data layer instead of duplicating a cache in context. (This is the discipline that dissolves the ~9 bespoke caches the data-fetching layer catalogues.)

Redux (Redux Toolkit and redux-persist) is being retired in favor of the context-and-hooks pattern on this page. It is not a peer tool to reach for in new code. Each thing the store does today maps onto a narrower mechanism:

  • Server-owned data (anything fetched from the backend) → the data-fetching layer’s cache, not a slice.
  • Auth and session → the data-fetching layer’s ArdaProvider, which owns the token lifecycle.
  • UI and session state (sidebar, locale, theme, per-view preferences) → atomic providers scoped by file-path hierarchy, exposing reader/writer hooks.
  • Persistence that redux-persist handled → useLocalStorage for device-local view state; genuine user settings go to the API (a future settings service, not yet designed), with useLocalStorage as an interim home until it exists.
  • Middleware (e.g. token refresh) → owned by the relevant provider; the refresh loop lives inside ArdaProvider.

New state does not go into a slice. When you touch a screen still backed by Redux, prefer moving its state onto this pattern over extending the slice. The end state has no store. When in doubt, prefer the narrowest tool: a component-local context over an app-wide one, with useLocalStorage for anything that must persist.

The rules above rule out a handful of common shapes:

  • Exporting the raw context object. Defeats the hook surface and lets consumers bypass the provider guard. Export the provider and hooks only.
  • A single “app context” bundling unrelated state. Causes over-broad re-renders and makes scope unfindable. Split by concern.
  • A silent default instead of a throwing one for writers. A mutation hook that no-ops outside its provider hides a wiring bug. Throw.
  • Hoisting a component-local concern to a global because the import was convenient. The path should reflect the true consumer span; if only one subtree consumes it, it lives in that subtree.
  • Putting server data in a bespoke context. Use the data-fetching layer.
  • Calling localStorage directly. Breaks server rendering, is not reactive, and scatters keys. Use the useLocalStorage hook.
  • Persisting a user setting in localStorage. A preference the user expects to follow them across devices belongs in the API, not device-local storage. Reserve localStorage for ephemeral, device-scoped view state.
  • Adding new state to a Redux slice. Redux is being retired; new state uses this pattern. Migrate slice state onto it rather than extending the store.