Skip to Content
QA Docs & Abangku 0.1.0 is released. Read more →
Product EngineerApplication State & Content Handling

Application State & Content Handling

9.1 Server state vs client state

This is where teams fragment fastest without an explicit rule: the same kind of data ends up in three different places depending on who wrote the feature. Use this boundary instead of judgment calls per feature:

  • URL state: anything that represents “where the user is” and should survive a refresh or be shareable via link, a filter, a selected tab, a pagination cursor, a search query. If a user would expect the back button or a copied link to restore it, it belongs in the URL, not in component state.
  • Server-cache state (TanStack Query, paired with tRPC, see 2.2): anything that originates from the server and can go stale, a fetched list, a record’s current values. This is not the same as client state that happens to hold server data, server-cache state is expected to be refetched, invalidated, and reconciled with the server as the source of truth.
  • Client-only state (Zustand or component state, see 2.2): anything that has no server representation at all, a modal’s open/closed state, a multi-step form’s current step before submission, UI-only toggles.
  • Real-time state (Azure Web PubSub, see 2.2): anything pushed to the client by the server rather than fetched, a live chat message, a live status update. This is not server-cache state, it isn’t refetched or revalidated on a schedule, the server pushes the update the moment it happens, and it isn’t cached at the fetch level at all, consistent with 9.2’s “never cache real-time data” rule. A received real-time update is applied directly (updating the relevant server-cache entry in place, or its own dedicated store), not queued behind the next scheduled refetch.

URL state is implemented with nuqs (see 2.2), not a hand-parsed useSearchParams string per call site. A URL parameter is defined once, with its type and default, and read/written through that single definition everywhere it’s used, so a filter’s shape and default value are never redefined slightly differently across the pages that read it.

Rule: if data comes from the server, it lives in server-cache state or real-time state (whichever matches how it arrives), never copied into a client-only store “for convenience.” Duplicating server data into client state is how two sources of truth drift apart, the client store shows stale data while the server-cache state has already refetched the real value. If a feature seems to need both, that’s a sign the derived value should be computed from server-cache state, not stored separately.

9.2 Caching and revalidation

  • Cache anything that tolerates staleness; never cache real-time data. A dashboard summary or a content list can be cached for a reasonable window. Anything the user expects to be live the instant it changes (a chat message, a live status) should not be cached at the fetch level at all.
  • Tag-based revalidation has a named owner per mutation. When a mutation changes data that’s cached elsewhere, the mutation itself is responsible for firing the corresponding revalidation, not the reader of the cached data guessing it might be stale. If a mutation can invalidate a cache tag, invalidating it is part of that mutation’s contract, not an optional follow-up.

Rule: every cached fetch has an explicit answer to “what invalidates this,” stated in code (a tag, a revalidation call) or in the PR description if the mechanism isn’t obvious from the code. A cache with no invalidation path is a bug waiting for a support ticket, not a performance optimization.

9.3 Schema validation

Zod (see 2.2) is the single source of truth for request/response and domain schemas, shared between client and server so validation logic is written once, not duplicated per side.

This is distinct from, but related to, the domain modeling in 6.8: 6.8 defines what a domain model, DB model, and API DTO are and how they differ; this subsection defines how the DTO/API shape is validated and kept in sync between client and server. Don’t duplicate that distinction here, cross-reference it.

  • A schema defined once is imported on both the client (form validation, see 5.10) and the server (boundary validation, see 11.3), so the two sides can never silently drift apart.
  • Contract-first API design (see 6.3) depends on this: the schema is written before the handler, and the same schema is what the client’s form validation is built against.

Rule: never hand-write a second validation check for a shape that already has a Zod schema, on either side. If validation logic needs to differ between client and server (e.g. the server also checks a database constraint the client can’t see), extend the shared schema rather than forking it into two separate, maintained-by-hand validators.

9.4 Search and filtering

Decision tree, in order:

  1. Start with DB-native query. Postgres full-text search, ILIKE, or the equivalent in the current database (see 6.6) is the default for any search or filter feature. Most search needs, filtering a list by a field, a simple keyword match, are well served by this without adding infrastructure.
  2. Escalate to dedicated search infra (Azure AI Search, see 2.2) only once a real, current requirement needs one of: fuzzy matching, typo tolerance, or faceted filtering at a scale DB-native query can’t serve acceptably. Don’t adopt dedicated search infra in anticipation of these needs; adopt it once a feature actually requires it.

Pagination convention: cursor-based, everywhere. This document picks one convention and enforces it rather than letting offset and cursor pagination coexist across features:

  • Cursor-based pagination is preferred over offset-based because it stays stable under concurrent writes, an offset-based page shifts underneath the user when rows are inserted or deleted between page loads; a cursor doesn’t.
  • This is the same convention required at the backend contract level in 6.3 (every list endpoint accepts cursor + limit, server enforces a maximum page size). This subsection is the reason that rule exists; 6.3 is where it’s enforced.

Rule: a new list or search feature that reaches for offset-based pagination, or that reaches for dedicated search infra before DB-native query has actually been tried and found insufficient, needs to justify why in the PR description. Neither is the default.

9.5 File and media handling

  • Upload directly to storage, not through the backend. Use presigned URLs (Azure Storage Blob, see 2.2) so large files go client to storage directly. Proxying file bytes through the backend wastes server capacity on pure data transfer and adds an unnecessary hop.
  • Validate on both sides, for different reasons. Client-side file type/size validation exists for UX, immediate feedback before an upload even starts. Server-side validation exists as the actual guard, since client-side checks can be bypassed entirely. Never treat client-side validation as sufficient on its own; the server re-validates type and size regardless of what the client already checked, consistent with 11.3.
  • Decide sync vs async processing deliberately. Anything beyond trivial post-processing, image resizing, transcoding, format conversion, belongs in a background job (see 10.4 for the threshold), not inline in the upload request. An upload response should confirm the file was received, not block on processing it.
  • Orphaned files are cleaned up, not left indefinitely. A presigned upload can complete in storage without ever being referenced by a successful record (the user abandoned the flow, the follow-up request failed). Run a periodic cleanup job that removes storage objects with no corresponding database reference past a reasonable grace period, don’t let storage accumulate orphaned files as an invisible, unbounded cost.

Rule: a feature that accepts file uploads is not complete until it has an answer for what happens when the upload succeeds but the follow-up step (saving the reference, processing the file) fails, that gap is exactly where orphaned files come from.

9.6 Offline and network-loss handling

This subsection is conditional, the same way multi-tenancy in 7.6 is: it applies once a product surface actually needs to remain usable, in some defined way, without a network connection. If that’s not a current requirement, skip it, don’t build offline handling speculatively ahead of an actual need (see 8.4 YAGNI).

Honest current state: no product surface requires offline support today. This document does not currently mandate an offline strategy; what follows is the criteria for when that changes, and the baseline behavior every feature already needs regardless of whether full offline support is ever built.

  • Every feature needs a defined behavior for network loss, even without full offline support. A mutation that fails because the network dropped mid-request surfaces through the normal error conventions (10.2), and TanStack Query’s retry behavior (see 2.2) handles the transient case of a brief connectivity blip automatically. This baseline is required today; it’s distinct from the optional, heavier commitment below.
  • Build actual offline support (local persistence, a sync/replay queue for actions taken while offline) only once a specific product surface has a concrete, stated need for it: a mobile-first or field-use feature where connectivity is genuinely unreliable in the context it’s used, not a general “it would be nice if the app worked offline” aspiration applied product-wide.
  • A feature built for offline use states its conflict-resolution strategy explicitly (last-write-wins, a merge strategy, surfacing the conflict to the user) before it ships, the same discipline this document already requires for eventual consistency in 8.10: an action taken offline and synced later is exactly the kind of delayed, out-of-order write that needs a stated resolution strategy, not an assumed one.

Rule: don’t build offline persistence or a sync queue for a feature without a concrete, stated need, per the same YAGNI discipline this document applies everywhere else. When a real need arrives, it’s evaluated and added here with its actual requirements, not built ahead of time as a general platform capability.

9.7 Browser storage policy

localStorage and sessionStorage are fully readable and writable by any script running on the page, including a third-party script (see 5.7) and, in the event of an XSS vulnerability, an attacker’s injected script. Treat them as untrusted, unencrypted client storage, not as a general-purpose place to persist application data.

  • Never store a secret, a session token, or an access token in localStorage or sessionStorage. A token stored there is directly readable by any script on the page, exactly the exposure 11.1 already warns about for a public-prefixed environment variable, session/auth state belongs in an httpOnly cookie or the centralized auth mechanism from 11.2, not in browser storage a script can read directly.
  • Acceptable uses are genuinely low-stakes: a UI preference (a collapsed sidebar, a chosen theme), a non-sensitive draft (see 9.8 below), a client-only cache of non-sensitive data that’s cheap to lose. If losing or exposing the value would matter, it doesn’t belong here.
  • sessionStorage over localStorage by default for anything that doesn’t need to survive across browser sessions. localStorage persists indefinitely until explicitly cleared, which is a longer exposure window and a longer-lived piece of state to remember exists and to clean up, use it only when the value genuinely needs to survive a closed tab or browser restart.

Rule: a PR that writes anything resembling a token, a secret, or personally identifying data to localStorage or sessionStorage is a review flag, the same severity as the secrets-management violations in 11.4, the storage mechanism is different but the exposure is the same class of problem.

9.8 Derived vs. stored state

A value computed from state that already exists should be computed at the point it’s needed, not stored as its own separate piece of state that then has to be kept in sync with the state it was derived from. This is distinct from the client/server state boundary in 9.1, which is about where state lives; this is about whether a given value should be state at all.

  • If a value can be computed from existing state or props, compute it, don’t store it. A filtered list, a total, a formatted string, a boolean like “is this list empty,” these are derivable from data that’s already there. Storing them separately creates a second representation of the same fact that can silently drift from the first the moment the source data changes and the derived copy isn’t updated to match.
  • The “two sources of truth drift apart” failure mode is the same one already named in 9.1 for duplicating server data into client state, applied one level down: a derived value stored separately from what it’s derived from is the same category of bug, just within a single component instead of across the client/server boundary.
  • Memoize a derived value for cost, not to make it “real” state. If recomputing a derived value is expensive enough to matter (see the measured-cost discipline in 5.20), memoize the computation, don’t promote the result into useState just to avoid recomputing it; a memoized derived value is still derived, it’s not a new, independently-set piece of state.

Rule: a useState (or store field) whose value is only ever set by copying or transforming another piece of state that’s already available is a review flag, compute it inline or with a memo instead. If it needs to be set independently, at least sometimes, from something other than its source, it’s genuinely its own state, and that’s fine, the flag is specifically for a value that’s always fully determined by state that already exists elsewhere.

9.9 Draft and autosave persistence

This is distinct from 9.6’s offline handling: this subsection is about not losing in-progress work on an accidental navigation, a refresh, or a closed tab, even with a normal network connection, offline handling is about continuing to work without one at all.

  • A long-form input (a multi-step form, a substantial text field) is a candidate for draft persistence once losing its content to an accidental refresh or navigation would be a real cost to the user, not applied by default to every input regardless of how trivial it is.
  • Persist to sessionStorage by default (see 9.7 above), scoped to the specific draft, not a server-persisted draft unless the content genuinely needs to survive across devices or sessions, that’s a materially heavier commitment (a real record, a cleanup policy) than a client-side draft.
  • A restored draft is surfaced explicitly to the user, “we restored your unsaved changes,” with an easy way to discard it, not silently reapplied without the user noticing content changed underneath them.
  • Clear the draft once the form actually submits successfully. A stale draft that persists after a successful submission and reappears on a later, unrelated visit to the same form is a bug, not a convenience, per the same “state that outlives its purpose” concern as an unremoved feature flag in 8.9.

Rule: a long-form input with no draft persistence needs a stated reason (the content is trivial to redo, or a server-persisted draft is the deliberate choice instead), the same way a missing loading or error state needs a reason per the 4.2 checklist. Losing a user’s substantial in-progress work to an accidental refresh is a usability defect, not an acceptable default.