f-ui
Design

Domain Status Vs Derived View

Separate persisted lifecycle status from derived business views, UI chrome, and capability gates — so frontend labels may differ from backend enums without inventing a second source of truth.

Use this page when frontend “status” and backend enums look inconsistent, or when you are about to add a new *Status / *Phase / canX flag. Mature products (Salesforce formulas, SAP CDS computed fields, CQRS read models, React state rules) treat that gap as normal when the UI is a projection — and as a bug when the client becomes a competing authority.

One-Line Rule

Derive display and capability from canonical facts; never invent a parallel status authority on the client.

When To Use

  • You need a badge, filter label, or “effective / overdue / expired” view that is not a workflow transition.
  • List, detail, and export must agree on what a label means.
  • You are tempted to useState a displayStatus updated from the API in an Effect.
  • Approve / Auto / AI / persona language is about to be folded into a lifecycle enum (usually wrong — keep facets orthogonal).

Use Approval And Case Patterns for multi-party surfaces on one instance. Use Status Tag only as the presentation of Kind A/B values. Use Form Layout — Derived Values for money totals inside forms (form density, not Statistic KPIs).

Taxonomy (Kinds A–D)

KindNameWho owns itPersist?Examples
ADomain lifecycleBackend write model / workflowYes — change via commandsdraftpending_*approvedreturned; order openpaid
BDerived business viewPrefer server formula / CDS / read DTO; client pure function only as documented B-liteUsually no (recomputed) or materialized projectionOffer / contract effective / expired from date window; Salesforce “Overdue”; SAP criticality CASE
CPresentation / chromeFrontend onlyNoModal open, active tab, StatusTag tone mapping
DCapability / gateShared pure function + server re-checkNocanSubmit, persona × status → edit vs read

“Inconsistent” FE vs BE is fine when the UI shows B/C/D projections of A.
It is a bug when the UI implies an action the API would reject, or two clients derive different labels from the same facts.

Naming

PreferAvoid
status / headerStatus for Kind AWritable displayStatus / feStatus / uiStatus
resolvedStatus, isOverdue, derived.* for Kind BReusing status for a client-only CASE
StatusTag tone for Kind CCalling color mapping a domain status
canSubmit, capabilities.* for Kind DPersisting gate flags on the aggregate

Decision Matrix

Is this a workflow transition the system must enforce?
├─ YES → Kind A (persist + commands). UI never invents transitions.
└─ NO  → Deterministically computable from Kind A (+ clock / config / related)?
         ├─ YES → Used for filter / report / other clients / jobs / authz / money?
         │        ├─ YES → Kind B on the server (formula / CDS / query DTO)
         │        └─ NO  → Kind B-lite: one pure deriveX(facts) + tests + docs
         └─ NO  → Kind C chrome — do not call it domain “status”

Gates (Kind D): hide or disable buttons from a shared function over wire facts; every command must re-validate on the server. Optimistic UI is allowed; the server remains authority.

Promote To Server (Required)

Move derivation off the SPA when any of these hold:

TriggerWhy
List filter / sort by the labelQuery must use one definition
Report / export / batch jobSecond consumer must match the badge
Second client (mobile, partner API)No SPA monopoly on meaning
Authz or money / compliance depends on itClient cannot be sole oracle
Two FE copies already disagreeFix by centralizing on the API

Industry anchors: Salesforce formula fields (recomputed on read, platform-owned); SAP CDS computed fields + Object Status criticality; CQRS read models as projections of the write model.

Keep On The Client (Allowed)

CaseRule
Pure formatMoney / date display from facts already on the wire
Kind C chromeLocal UI only; never drives ACL
Kind B-liteCheap, deterministic, single deriveX; inject asOf when time-dependent; document that production may promote
Kind D preflightSame helper the tests use; server still enforces

React: Avoid redundant state — if you can calculate it during render, do not put it in state. Do not sync derived status with an Effect (You Might Not Need an Effect).

API Contract Shape

Resource (query / view)
├─ status              ← Kind A (lifecycle)
├─ …facts              ← dates, amounts, flags Kind B needs
├─ resolvedStatus?     ← Kind B (read-only; server-computed preferred)
├─ isOverdue?          ← Kind B
└─ capabilities?       ← optional Kind D hints (never sole ACL)

Commands
└─ accept only Kind A transitions + business facts
   reject illegal transitions regardless of what the UI showed

Do not accept a client-mapped “display enum” as the write value for lifecycle.

Frontend Authoring

  1. One mapper per feature: Kind A/B → Status Tag label + tone.
  2. One deriveX (or one server field) per derived concept — no copy-pasted CASE in list, detail, and Excel.
  3. Time: pass asOf into derivations; do not scatter new Date() with different timezones.
  4. Gates: canX(facts) shared; button disabled when false; submit path still hits the API.
  5. Role surfaces: viewer × node × instance matrices (Approval And Case Patterns) are projections over Kind A + policy — not a FE-only status machine.

Forbidden

SmellWhy
useState(displayStatus) + Effect from APIDrift between tabs / refresh
FE-only statuses used for routing or ACLCompeting authority
Write-back of mapped FE enum to statusDual write / lost in translation
Auto / AI / persona values inside lifecycle enumCombinatorial explosion — keep outcome ⊥ provenance
Business rules inside StatusTagTag is presentation only

Orthogonal Facets (Approvals)

When product language says “Approved Auto”, do not add a fourth lifecycle literal. Split:

FacetHoldsTable chrome
Outcomepending / approved / rejected (Kind A or line decision)Primary Status Tag
Provenancesystem vs human (and rule id / comment)Optional secondary Label Tag Auto in the same Status cell — see Tag Selection — Multiple Pills
Attentioninbox defaults (“needs human”)Filter / LO — not a third StatusTag

See Approval And Case Patterns and Tag Selection — Multiple Pills.

Worked Picture

Backend write model ──status (A)──► Query DTO (+ optional resolved* B)
         │                                │
         │                         TanStack Query cache
         │                                │
         └──────── facts ─────────────────┴──► deriveX? (B-lite)

                              StatusTag ←─────┤
                              canSubmit ←─────┤ (D; server re-checks)
                              modalOpen ←─────┘ (C only)

Authoring Checklist

  • New *Status / *Phase classified as Kind A / B / C / D.
  • Kind A: backend enum + command transitions listed.
  • Kind B: server read field or single pure deriveX with inputs / asOf / tests documented.
  • Kind D: server re-validation explicitly required.
  • No redundant React state for calculable values.
  • List + detail (+ export) share one definition for any badge users filter on.
  • StatusTag mapper has no domain transition logic.

Testing

KindMinimum
ALegal / illegal transition tests on the API or domain
B / B-liteGolden tests: fixed facts (+ asOf) → fixed view
DUI disabled when false; API rejects when the gate is false
Cross-surfaceList and detail that show the same label import the same helper or field

Examples

Wrong Display Status State

Left side syncs a client displayStatus from the API in an Effect — a second authority that drifts across tabs. Right side keeps Kind A on the wire and shows Kind B as a separate derived field (no parallel status state).

Wrong

Purchase request

api.status = approved

useState(displayStatus)effective

Effect maps API → displayStatus on every fetch

Effective

Client invents a second status authority — tabs / refresh can drift

Right

Purchase request

status = approved (Kind A)

resolvedStatus = effective (Kind B)

No displayStatus state — render from wire facts

ApprovedEffective

Kind A lifecycle + Kind B derived view — one source of truth

import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function DomainStatusWrongDisplayStatusDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Purchase request
          </p>
          <div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
            <p>
              <span className="text-muted-foreground">api.status</span>
              {" = "}
              <span className="text-foreground">approved</span>
            </p>
            <p>
              <span className="text-muted-foreground">useState(displayStatus)</span>
              {" → "}
              <span className="text-destructive">effective</span>
            </p>
            <p className="text-muted-foreground">
              Effect maps API → displayStatus on every fetch
            </p>
          </div>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="success">Effective</StatusTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Client invents a second status authority — tabs / refresh can drift
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Purchase request
          </p>
          <div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
            <p>
              <span className="text-muted-foreground">status</span>
              {" = "}
              <span className="text-foreground">approved</span>
              <span className="text-muted-foreground"> (Kind A)</span>
            </p>
            <p>
              <span className="text-muted-foreground">resolvedStatus</span>
              {" = "}
              <span className="text-foreground">effective</span>
              <span className="text-muted-foreground"> (Kind B)</span>
            </p>
            <p className="text-muted-foreground">
              No displayStatus state — render from wire facts
            </p>
          </div>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="success">Approved</StatusTag>
            <StatusTag tone="info">Effective</StatusTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Kind A lifecycle + Kind B derived view — one source of truth
          </p>
        </div>
      }
    />
  );
}

Four panels for Kind A lifecycle, Kind B derived view, Kind C chrome, and Kind D capability gates — so “status” language always maps to one owner.

A

Domain lifecycle

Persisted write model — changes only via commands

DraftPending approvalApproved

Order: open → paid

B

Derived business view

Recomputed from Kind A + dates — prefer server read field

EffectiveExpiredOverdue

Offer window / Salesforce overdue — not a workflow step

C

Presentation / chrome

Frontend only — never ACL or filter authority

Active tab:

Modal open, tab, StatusTag tone mapping

D

Capability / gate

Shared canX(facts) — server re-checks every command

canSubmit = false (draft incomplete)

Hide or disable from wire facts; never persist gate flags

import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";

function KindPanel({
  kind,
  title,
  children,
}: {
  kind: string;
  title: string;
  children: ReactNode;
}) {
  return (
    <div className="rounded-xl border bg-card p-4 space-y-3">
      <div className="flex items-center gap-2">
        <LabelTag accent="neutral">{kind}</LabelTag>
        <p className="text-sm font-medium text-foreground">{title}</p>
      </div>
      {children}
    </div>
  );
}

export function DomainStatusKindsGalleryDemo() {
  return (
    <div className="grid gap-4 sm:grid-cols-2">
      <KindPanel kind="A" title="Domain lifecycle">
        <p className="text-xs text-muted-foreground">
          Persisted write model — changes only via commands
        </p>
        <div className="flex flex-wrap items-center gap-1.5">
          <StatusTag tone="neutral">Draft</StatusTag>
          <StatusTag tone="warning">Pending approval</StatusTag>
          <StatusTag tone="success">Approved</StatusTag>
        </div>
        <p className="text-xs text-muted-foreground">Order: open → paid</p>
      </KindPanel>

      <KindPanel kind="B" title="Derived business view">
        <p className="text-xs text-muted-foreground">
          Recomputed from Kind A + dates — prefer server read field
        </p>
        <div className="flex flex-wrap items-center gap-1.5">
          <StatusTag tone="info">Effective</StatusTag>
          <StatusTag tone="destructive">Expired</StatusTag>
          <StatusTag tone="warning">Overdue</StatusTag>
        </div>
        <p className="text-xs text-muted-foreground">
          Offer window / Salesforce overdue — not a workflow step
        </p>
      </KindPanel>

      <KindPanel kind="C" title="Presentation / chrome">
        <p className="text-xs text-muted-foreground">
          Frontend only — never ACL or filter authority
        </p>
        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs text-muted-foreground">Active tab:</span>
          <Button variant="secondary">
            Overview
          </Button>
          <Button variant="ghost">
            History
          </Button>
        </div>
        <p className="text-xs text-muted-foreground">
          Modal open, tab, StatusTag tone mapping
        </p>
      </KindPanel>

      <KindPanel kind="D" title="Capability / gate">
        <p className="text-xs text-muted-foreground">
          Shared canX(facts) — server re-checks every command
        </p>
        <div className="flex flex-wrap items-center gap-2">
          <Button disabled>Submit for approval</Button>
          <span className="text-xs text-muted-foreground">
            canSubmit = false (draft incomplete)
          </span>
        </div>
        <p className="text-xs text-muted-foreground">
          Hide or disable from wire facts; never persist gate flags
        </p>
      </KindPanel>
    </div>
  );
}

Orthogonal Facets

Left side folds “Approved Auto” into one lifecycle literal. Right side splits outcome (StatusTag), provenance (LabelTag), and the submit gate — facets stay independent.

Wrong

Purchase line decision

Approved auto

enum: draft | pending | approved | approved_auto | …

Provenance folded into lifecycle → combinatorial explosion

One mega-status drives badge, filter, and gate language

Right

Purchase line decision

ApprovedAuto

Outcome — Kind A: approved

Provenance — LabelTag Auto (same status cell)

Gate — Kind D: canSubmit already satisfied

Lifecycle ⊥ provenance ⊥ gate — facets stay orthogonal

import { Button } from "@/components/ui/button";
import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function DomainStatusOrthogonalFacetsDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Purchase line decision
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="success">Approved auto</StatusTag>
          </div>
          <div className="space-y-1 rounded-lg border bg-muted/30 p-3 font-mono text-xs text-muted-foreground">
            <p>enum: draft | pending | approved | approved_auto | …</p>
            <p className="text-destructive">
              Provenance folded into lifecycle → combinatorial explosion
            </p>
          </div>
          <Button>Submit for approval</Button>
          <p className="text-xs text-muted-foreground">
            One mega-status drives badge, filter, and gate language
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Purchase line decision
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="success">Approved</StatusTag>
            <LabelTag accent="neutral">Auto</LabelTag>
          </div>
          <div className="space-y-1.5 text-xs">
            <p>
              <span className="font-medium text-foreground">Outcome</span>
              <span className="text-muted-foreground">
                {" "}
                — Kind A: approved
              </span>
            </p>
            <p>
              <span className="font-medium text-foreground">Provenance</span>
              <span className="text-muted-foreground">
                {" "}
                — LabelTag Auto (same status cell)
              </span>
            </p>
            <p>
              <span className="font-medium text-foreground">Gate</span>
              <span className="text-muted-foreground">
                {" "}
                — Kind D: canSubmit already satisfied
              </span>
            </p>
          </div>
          <Button variant="outline" disabled>
            Submit for approval
          </Button>
          <p className="text-xs text-muted-foreground">
            Lifecycle ⊥ provenance ⊥ gate — facets stay orthogonal
          </p>
        </div>
      }
    />
  );
}

Overdue As Derived View

Left side promotes overdue into a write-model enum when the due date passes. Right side keeps Kind A open and derives isOverdue from the date window with an explicit asOf.

Wrong

Shipping label

status = overdue

New lifecycle literal when dueDate < today

Overdue

Clock movement becomes a write-model transition — wrong Kind

Right

Shipping label

status = open (Kind A)

dueDate = 2026-08-01

isOverdue = true (Kind B, asOf=today)

OpenOverdue

Date-window view derived from facts — not a new enum

import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function DomainStatusOverdueDerivedDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Shipping label
          </p>
          <div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
            <p>
              <span className="text-muted-foreground">status</span>
              {" = "}
              <span className="text-destructive">overdue</span>
            </p>
            <p className="text-muted-foreground">
              New lifecycle literal when dueDate &lt; today
            </p>
          </div>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="destructive">Overdue</StatusTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Clock movement becomes a write-model transition — wrong Kind
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Shipping label
          </p>
          <div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs">
            <p>
              <span className="text-muted-foreground">status</span>
              {" = "}
              <span className="text-foreground">open</span>
              <span className="text-muted-foreground"> (Kind A)</span>
            </p>
            <p>
              <span className="text-muted-foreground">dueDate</span>
              {" = "}
              <span className="text-foreground">2026-08-01</span>
            </p>
            <p>
              <span className="text-muted-foreground">isOverdue</span>
              {" = "}
              <span className="text-foreground">true</span>
              <span className="text-muted-foreground">
                {" "}
                (Kind B, asOf=today)
              </span>
            </p>
          </div>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="info">Open</StatusTag>
            <StatusTag tone="warning">Overdue</StatusTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Date-window view derived from facts — not a new enum
          </p>
        </div>
      }
    />
  );
}

API Contract Shape

Left side writes a client-mapped display enum back as lifecycle status. Right side shows the preferred read DTO: Kind A status, facts, derived.*, and optional capability hints — commands accept Kind A only.

Wrong

Order read → write

// Client maps then writes back

GET status: "open"

FE maps → displayStatus: "overdue"

PATCH { status: "overdue" }

Overdue

Display enum accepted as lifecycle write — dual authority

Right

Order query DTO

status: "open" ← Kind A

dueDate: "2026-08-01"

derived: {

isOverdue: true,

resolvedStatus: "overdue"

}

capabilities: { canMarkPaid: true } ← Kind D hint

OpenOverdue

Commands accept Kind A transitions only — derived.* is read-only

import { Button } from "@/components/ui/button";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function DomainStatusApiContractDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Order read → write
          </p>
          <div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs leading-relaxed">
            <p className="text-muted-foreground">// Client maps then writes back</p>
            <p>
              <span className="text-muted-foreground">GET</span>
              {" status: "}
              <span className="text-foreground">&quot;open&quot;</span>
            </p>
            <p>
              <span className="text-muted-foreground">FE maps →</span>
              {" displayStatus: "}
              <span className="text-destructive">&quot;overdue&quot;</span>
            </p>
            <p>
              <span className="text-muted-foreground">PATCH</span>
              {" { status: "}
              <span className="text-destructive">&quot;overdue&quot;</span>
              {" }"}
            </p>
          </div>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="destructive">Overdue</StatusTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Display enum accepted as lifecycle write — dual authority
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Order query DTO
          </p>
          <div className="space-y-2 rounded-lg border bg-muted/30 p-3 font-mono text-xs leading-relaxed">
            <p>
              <span className="text-foreground">status</span>
              <span className="text-muted-foreground">: &quot;open&quot;</span>
              <span className="text-muted-foreground"> ← Kind A</span>
            </p>
            <p>
              <span className="text-foreground">dueDate</span>
              <span className="text-muted-foreground">: &quot;2026-08-01&quot;</span>
            </p>
            <p>
              <span className="text-foreground">derived</span>
              <span className="text-muted-foreground">: {"{"}</span>
            </p>
            <p className="pl-3">
              <span className="text-foreground">isOverdue</span>
              <span className="text-muted-foreground">: true,</span>
            </p>
            <p className="pl-3">
              <span className="text-foreground">resolvedStatus</span>
              <span className="text-muted-foreground">: &quot;overdue&quot;</span>
            </p>
            <p>
              <span className="text-muted-foreground">{"}"}</span>
            </p>
            <p>
              <span className="text-foreground">capabilities</span>
              <span className="text-muted-foreground">
                : {"{ canMarkPaid: true }"} ← Kind D hint
              </span>
            </p>
          </div>
          <div className="flex flex-wrap items-center gap-2">
            <StatusTag tone="info">Open</StatusTag>
            <StatusTag tone="warning">Overdue</StatusTag>
            <Button>Mark paid</Button>
          </div>
          <p className="text-xs text-muted-foreground">
            Commands accept Kind A transitions only — derived.* is read-only
          </p>
        </div>
      }
    />
  );
}

See Also

On this page