Build CoreOrdered learning track

State Invariants

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 055

A production-grade guide to state invariants in React: illegal states, transition guards, discriminated unions, reducer rules, consistency checks, async invariants, and invariant-driven tests.

8 min read1565 words
PrevNext
Lesson 55123 lesson track24–67 Build Core
#react#hooks#state-management#invariants+3 more

Part 055 — State Invariants

A state invariant is a rule that must always be true for a piece of UI state to be valid.

Most React bugs are not caused by useState itself.

They are caused by state shapes that allow impossible combinations.

isLoading = true
error = "Network failed"
data = {...}
selectedId = "case-123"
entities["case-123"] = undefined
step = "review"
validation.status = "idle"
modal.open = false
modal.payload = {...}

Each individual value may look reasonable.

The combination is wrong.

A senior React engineer does not only ask:

How do I update this state?

They ask:

What states are legal?
What states are impossible?
Which transitions may move between them?
Which component owns those transitions?
Which tests prove those invariants?

This part is about building React UI state as a small correctness system.


1. What Is a State Invariant?

An invariant is a condition that must stay true before and after every transition.

Examples:

A selected item id must exist in the entity map.
A closed modal must not keep stale payload.
A submitting form cannot be edited unless editing is explicitly allowed.
A successful request must have data and no error.
An optimistic entity must have a client temp id until confirmed.
A wizard cannot enter review step before required fields are valid.
Only one accordion item can be open in single-select mode.
A dialog stack top item is the only item that receives Escape key close.

In React, an invariant usually spans:

state shape
component identity
props contract
context value
external store snapshot
server cache data
URL parameters
pending async work

A bug appears when a transition mutates one part of that graph but forgets another.


2. State Without Invariants Becomes Boolean Soup

A common async shape:

const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<CaseDetail | null>(null);

This allows combinations that should not exist:

isLoading=true, error!=null, data!=null
isLoading=false, error=null, data=null
isLoading=true, error!=null, data=null

Maybe some are legal.

Maybe not.

The problem is that the shape does not say.

Better:

type CaseDetailState =
  | { tag: 'idle' }
  | { tag: 'loading' }
  | { tag: 'success'; data: CaseDetail }
  | { tag: 'failure'; error: ErrorInfo };

Now impossible combinations are unrepresentable.

success requires data
failure requires error
loading cannot also have stale error unless explicitly modeled
idle cannot accidentally contain payload

The strongest invariant is the one your state shape makes impossible to violate.


3. The Core Rule: Model the State Machine, Not the Widgets

Weak state design mirrors widgets:

isDialogOpen
isSubmitting
isConfirming
isDirty
isValid
errorMessage
selectedId

Strong state design models the domain interaction:

type DeleteCaseFlow =
  | { tag: 'idle' }
  | { tag: 'confirming'; caseId: CaseId }
  | { tag: 'submitting'; caseId: CaseId; requestId: string }
  | { tag: 'failed'; caseId: CaseId; error: ErrorInfo }
  | { tag: 'completed'; caseId: CaseId };

The UI then follows the state.

confirming -> dialog is open
submitting -> confirm button disabled
failed -> dialog stays open with error
completed -> dialog closes and cache invalidates

The state model is not a mirror of HTML.

It is a protocol for user interaction.


4. Invariant Layers

React applications have several invariant layers.

A component can be correct locally but wrong globally.

Example:

The dropdown renders selectedId correctly.
But selectedId points to an entity removed by another store transition.

Invariant design must follow the ownership graph.


5. The Three Places to Enforce Invariants

There are three useful enforcement points.

Enforcement pointExampleStrengthCost
Type/state shapediscriminated unionhighestdesign effort
Transition guardreducer rejects illegal eventhighreducer complexity
Runtime assertionthrow/log/report invalid statemediumruntime overhead

The order matters.

Prefer:

make illegal states unrepresentable
then guard illegal transitions
then assert impossible edge cases

Do not rely only on component rendering branches to hide invalid states.

Rendering branches detect symptoms.

State invariants prevent the disease.


6. Invariants as First-Class Design Artifacts

For every non-trivial state, write invariants before implementation.

Example: case assignment panel.

State owner:
  AssignmentPanel

State:
  selectedAssigneeId
  eligibleAssignees
  currentAssigneeId
  submitStatus

Invariants:
  selectedAssigneeId must be null or exist in eligibleAssignees
  selectedAssigneeId may equal currentAssigneeId only when no change is pending
  submit button is enabled only when selectedAssigneeId differs from currentAssigneeId
  submitting state captures the assignee id being submitted
  response for old request cannot overwrite newer request
  currentAssigneeId updates only after confirmed success or optimistic commit

This looks like ceremony.

It is cheaper than debugging a production race where the UI submits assignee A but renders assignee B.


7. Bad Shape: Parallel Booleans

Parallel booleans often create invalid combinations.

type ModalState = {
  isOpen: boolean;
  isConfirming: boolean;
  isSubmitting: boolean;
  isError: boolean;
  caseId?: string;
  error?: string;
};

Invalid combinations:

isOpen=false and caseId exists
isSubmitting=true and caseId missing
isConfirming=true and isSubmitting=true
isError=true and error missing
isOpen=false and error visible in memory

Better:

type DeleteModalState =
  | { tag: 'closed' }
  | { tag: 'confirming'; caseId: string }
  | { tag: 'submitting'; caseId: string; requestId: string }
  | { tag: 'failed'; caseId: string; error: string };

Rendering becomes direct:

function DeleteCaseDialog({ state, onCancel, onConfirm }: Props) {
  if (state.tag === 'closed') return null;

  return (
    <Dialog open onOpenChange={(open) => !open && onCancel()}>
      <Dialog.Title>Delete case?</Dialog.Title>

      {state.tag === 'failed' && <ErrorText>{state.error}</ErrorText>}

      <Button
        disabled={state.tag === 'submitting'}
        onClick={() => onConfirm(state.caseId)}
      >
        {state.tag === 'submitting' ? 'Deleting...' : 'Delete'}
      </Button>
    </Dialog>
  );
}

Notice what disappeared:

no boolean combinations
no optional payload checks scattered everywhere
no stale payload after close
no ambiguous loading+error state

8. Illegal States Should Be Expensive to Create

A weak type makes bad state easy:

type FormState = {
  step: 'details' | 'review' | 'submit';
  details?: Details;
  validationErrors?: Record<string, string>;
  submittedId?: string;
};

This allows:

step=review with no details
step=submit with validationErrors
submittedId exists while step=details

Make legal states explicit:

type FormState =
  | { tag: 'editingDetails'; draft: DetailsDraft; errors: FieldErrors }
  | { tag: 'reviewing'; details: ValidDetails }
  | { tag: 'submitting'; details: ValidDetails; requestId: string }
  | { tag: 'submitted'; submissionId: string };

The type now documents the journey.

editingDetails has draft and errors
reviewing has valid details
submitting has valid details and request identity
submitted has server submission id

9. Derived Invariants

Some invariants do not need stored state.

Bad:

const [items, setItems] = useState<Item[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<Item | null>(null);

Now selectedItem can drift from selectedId.

Better:

const selectedItem = selectedId
  ? items.find((item) => item.id === selectedId) ?? null
  : null;

Invariant:

selectedItem is always derived from selectedId and items

If the derivation is expensive, memoize the index or selector.

Do not duplicate truth just to avoid writing a selector.


10. Referential Invariants

When state stores identifiers, the referenced entity must exist.

type CaseState = {
  selectedCaseId: string | null;
  casesById: Record<string, CaseSummary>;
};

Invariant:

function assertCaseState(state: CaseState) {
  if (state.selectedCaseId && !state.casesById[state.selectedCaseId]) {
    throw new Error(`selectedCaseId does not exist: ${state.selectedCaseId}`);
  }
}

A reducer should maintain the invariant:

function reducer(state: CaseState, event: Event): CaseState {
  switch (event.type) {
    case 'caseSelected': {
      if (!state.casesById[event.caseId]) {
        return state; // or throw in development
      }

      return { ...state, selectedCaseId: event.caseId };
    }

    case 'caseRemoved': {
      const { [event.caseId]: removed, ...casesById } = state.casesById;

      return {
        casesById,
        selectedCaseId:
          state.selectedCaseId === event.caseId ? null : state.selectedCaseId,
      };
    }
  }
}

The remove transition updates both entity table and selection.

That is invariant preservation.


11. Transition Invariants

A reducer transition has two responsibilities:

accept legal events
preserve invariants in the next state

Example:

type UploadState =
  | { tag: 'idle' }
  | { tag: 'selecting'; file: File }
  | { tag: 'uploading'; file: File; requestId: string; progress: number }
  | { tag: 'failed'; file: File; error: string }
  | { tag: 'uploaded'; fileId: string };

type UploadEvent =
  | { type: 'fileSelected'; file: File }
  | { type: 'uploadStarted'; requestId: string }
  | { type: 'progressChanged'; requestId: string; progress: number }
  | { type: 'uploadFailed'; requestId: string; error: string }
  | { type: 'uploadSucceeded'; requestId: string; fileId: string }
  | { type: 'cancelled' };

Reducer:

function uploadReducer(state: UploadState, event: UploadEvent): UploadState {
  switch (event.type) {
    case 'fileSelected':
      return { tag: 'selecting', file: event.file };

    case 'uploadStarted':
      if (state.tag !== 'selecting') return state;
      return {
        tag: 'uploading',
        file: state.file,
        requestId: event.requestId,
        progress: 0,
      };

    case 'progressChanged':
      if (state.tag !== 'uploading') return state;
      if (state.requestId !== event.requestId) return state;
      return {
        ...state,
        progress: Math.max(state.progress, event.progress),
      };

    case 'uploadFailed':
      if (state.tag !== 'uploading') return state;
      if (state.requestId !== event.requestId) return state;
      return { tag: 'failed', file: state.file, error: event.error };

    case 'uploadSucceeded':
      if (state.tag !== 'uploading') return state;
      if (state.requestId !== event.requestId) return state;
      return { tag: 'uploaded', fileId: event.fileId };

    case 'cancelled':
      return { tag: 'idle' };
  }
}

Important invariants:

progress cannot arrive before uploadStarted
old request responses cannot overwrite current state
progress cannot go backwards
failed keeps file for retry
uploaded contains server file id, not local File object
cancel resets payload

12. Guard Strategy: Ignore, Throw, or Recover?

When an illegal transition happens, choose a strategy.

StrategyUse whenExample
Ignoreduplicate event or stale response is harmlessold request completes after new request
Throw in devtransition should never happensubmit event during idle state
Recoverstate can be repaired safelyselected id removed, clear selection
Reportinvariant breach indicates serious bugnormalized relation points to missing entity

Example helper:

function invariant(condition: boolean, message: string): asserts condition {
  if (!condition) {
    if (process.env.NODE_ENV !== 'production') {
      throw new Error(message);
    }

    console.error(message);
  }
}

Use runtime assertions at boundaries, not in every render path.


13. Invariants and State Snapshots

React state is a snapshot per render.

That means an event handler sees the values from the render that created it.

Weak pattern:

async function handleSave() {
  setStatus('saving');
  await saveDraft(draft);
  setStatus('saved');
  setSavedDraft(draft);
}

If draft changes while the request is pending, the handler still saves the old snapshot.

That may be fine.

But the invariant must say so.

Better:

async function handleSave() {
  const requestId = crypto.randomUUID();
  const submittedDraft = draft;

  dispatch({ type: 'saveStarted', requestId, draft: submittedDraft });

  try {
    const saved = await saveDraft(submittedDraft);
    dispatch({ type: 'saveSucceeded', requestId, saved });
  } catch (error) {
    dispatch({ type: 'saveFailed', requestId, error: toErrorInfo(error) });
  }
}

Reducer ignores stale responses.

case 'saveSucceeded': {
  if (state.tag !== 'saving') return state;
  if (state.requestId !== event.requestId) return state;
  return { tag: 'saved', saved: event.saved };
}

Invariant:

Only the active request may complete the active save state.

14. Invariants Around Async Work

Async UI creates temporal states.

You must model time explicitly.

This machine documents important decisions:

Can the user edit while saving?
Can a save complete after draft changed?
Does success mark the current draft saved or the submitted draft saved?
Does retry use latest draft or failed request draft?

Do not hide these decisions inside incidental setState calls.


15. Request Identity Is an Invariant Tool

Any async transition that can overlap needs identity.

type SearchState =
  | { tag: 'idle'; query: string }
  | { tag: 'loading'; query: string; requestId: string }
  | { tag: 'success'; query: string; results: SearchResult[] }
  | { tag: 'failure'; query: string; error: ErrorInfo };

When the user types fast:

q=a      request r1
q=ab     request r2
q=abc    request r3
r1 returns after r3

Without identity, r1 can overwrite r3.

With identity:

case 'searchSucceeded': {
  if (state.tag !== 'loading') return state;
  if (state.requestId !== event.requestId) return state;
  return { tag: 'success', query: state.query, results: event.results };
}

Invariant:

Only the latest active request may commit visible results.

16. Form Invariants

Forms are invariant-heavy.

Common invariants:

field error belongs to current field value
form-level validity derives from current field states
submit uses validated snapshot, not mutable current draft
server error belongs to submitted payload version
reset clears dirty/touched/errors consistently
hidden conditional fields do not submit stale values unless explicitly retained

Weak form state:

type FormState = {
  values: Values;
  errors: Errors;
  touched: Touched;
  isValid: boolean;
  isSubmitting: boolean;
  serverError?: string;
};

Better mental model:

values      = user draft
errors      = validation result for a specific values version
touched     = interaction metadata
submit      = command with values snapshot
serverError = response for submitted snapshot

Add versioning when needed:

type FormState = {
  values: Values;
  version: number;
  validation: {
    version: number;
    errors: Errors;
  };
  submission:
    | { tag: 'idle' }
    | { tag: 'submitting'; requestId: string; version: number; values: Values }
    | { tag: 'failed'; version: number; errors: ServerErrors }
    | { tag: 'succeeded'; id: string };
};

Now server errors can be invalidated if the user edits.


17. Modal Invariants

Modal bugs often come from stale payload.

Weak:

const [open, setOpen] = useState(false);
const [caseId, setCaseId] = useState<string | null>(null);

Better:

type ModalState =
  | { tag: 'closed' }
  | { tag: 'open'; caseId: string };

Invariant:

closed modal has no payload
open modal always has payload

For multiple modals:

type Overlay =
  | { kind: 'deleteCase'; caseId: string }
  | { kind: 'assignCase'; caseId: string }
  | { kind: 'previewDocument'; documentId: string };

type OverlayState = {
  stack: Overlay[];
};

Stack invariants:

Escape closes only the top overlay
focus trap belongs to top modal
background is inert if stack length > 0
body scroll lock count equals modal stack depth

18. Selection Invariants

Selection state usually references a collection.

Single select:

type SelectionState = {
  selectedId: string | null;
  visibleIds: string[];
};

Invariants:

selectedId is null or included in visibleIds
if filter removes selected item, either clear selection or preserve global selection explicitly
keyboard focus id may differ from selected id

Multi-select:

type SelectionState = {
  selectedIds: Set<string>;
  allIds: string[];
  visibleIds: string[];
};

Invariants:

selectedIds is a subset of allIds
visible selection count derives from selectedIds ∩ visibleIds
select all visible is not the same as select all global
bulk action payload must define whether it targets visible, selected, or query-matched entities

These are not UI details.

They are product correctness rules.


19. Normalized State Invariants

Normalized state from Part 054 depends on referential integrity.

type EntityState = {
  cases: Record<string, CaseEntity>;
  comments: Record<string, CommentEntity>;
};

Invariants:

every comment.caseId exists in cases
every case.commentIds item exists in comments
relationship order is stored exactly once
entity table does not contain temp and confirmed id for the same logical entity
partial response cannot erase fields unless explicitly marked as partial

A production reducer should preserve both entity and relation tables.

case 'commentDeleted': {
  const comments = { ...state.comments };
  delete comments[event.commentId];

  return {
    ...state,
    comments,
    cases: {
      ...state.cases,
      [event.caseId]: {
        ...state.cases[event.caseId],
        commentIds: state.cases[event.caseId].commentIds.filter(
          (id) => id !== event.commentId,
        ),
      },
    },
  };
}

The bug is not deleting a comment.

The bug is deleting the entity but leaving the relation.


20. URL State Invariants

URL state is public, user-editable, and shareable.

Therefore it must be parsed and canonicalized.

type SearchParamsState = {
  page: number;
  sort: 'createdAt' | 'priority';
  direction: 'asc' | 'desc';
  status: 'open' | 'closed' | 'all';
};

Invariants:

page >= 1
sort is one of allowed fields
direction is valid
status is valid
invalid params become canonical defaults
URL does not contain private tokens or sensitive filters
query key derives from canonical params, not raw search string

Do not trust raw URL params.

Treat them like input from the network.


21. Persistent State Invariants

Persistent browser state can outlive the code that wrote it.

Invariants:

stored value has schema version
stored value passes validation before use
unknown version migrates or resets
logout clears identity-bound state
sensitive state is not persisted casually
hydrated value cannot contradict server session

Example:

type PersistedPreferencesV2 = {
  version: 2;
  density: 'compact' | 'comfortable';
  sidebarCollapsed: boolean;
};

function parsePreferences(raw: string | null): PersistedPreferencesV2 {
  if (!raw) return defaultPreferences;

  try {
    const value = JSON.parse(raw);

    if (value.version === 2 && isValidPreferences(value)) {
      return value;
    }

    return defaultPreferences;
  } catch {
    return defaultPreferences;
  }
}

Persistence without validation is not state management.

It is state archaeology.


22. Context Invariants

Context carries implicit dependencies.

Important invariants:

consumer must be under provider unless optional fallback is intentional
provider value identity must be stable enough for its volatility
state context and command context may need separate providers
capability context should not expose mutable implementation details
permission context must not become security enforcement

Strict context hook:

function createStrictContext<T>(name: string) {
  const Context = createContext<T | null>(null);

  function useStrictContext() {
    const value = useContext(Context);

    if (value === null) {
      throw new Error(`${name} must be used within ${name}.Provider`);
    }

    return value;
  }

  return [Context.Provider, useStrictContext] as const;
}

Invariant:

Consumer never silently runs with fake default capability.

23. Component API Invariants

Reusable components must protect their public contract.

Example: controlled/uncontrolled component.

type ControllableProps<T> = {
  value?: T;
  defaultValue?: T;
  onValueChange?: (next: T) => void;
};

Invariants:

component is controlled if value is not undefined on first render
component should not switch controlledness after mount
controlled component does not mutate internal source of truth
uncontrolled component owns internal state
onValueChange emits intent, not guaranteed commit

Development guard:

function useControllednessWarning(name: string, isControlled: boolean) {
  const initial = useRef(isControlled);

  if (process.env.NODE_ENV !== 'production') {
    if (initial.current !== isControlled) {
      console.error(
        `${name} changed from ${initial.current ? 'controlled' : 'uncontrolled'} ` +
          `to ${isControlled ? 'controlled' : 'uncontrolled'}.`,
      );
    }
  }
}

This protects component consumers from ambiguous ownership.


24. Accessibility Invariants

Accessibility state is state.

Examples:

a dialog that is visually open must expose modal semantics
focus must move into modal and return after close
aria-expanded must match actual disclosure state
aria-controls must reference an existing controlled element
active descendant id must exist in the listbox options
selected tab must control the visible panel
error message id must be referenced by invalid field

A UI can appear to work while violating these invariants.

Production component libraries should test them.


25. Invariant-Driven Reducer Design

Reducer design should start from allowed transitions.

State: idle
  event: started -> loading
  event: cancelled -> idle

State: loading
  event: succeeded(active request) -> success
  event: failed(active request) -> failure
  event: cancelled -> idle

State: success
  event: refreshed -> loading
  event: reset -> idle

State: failure
  event: retry -> loading
  event: reset -> idle

Then implement.

function reducer(state: State, event: Event): State {
  switch (state.tag) {
    case 'idle':
      return reduceIdle(state, event);
    case 'loading':
      return reduceLoading(state, event);
    case 'success':
      return reduceSuccess(state, event);
    case 'failure':
      return reduceFailure(state, event);
  }
}

This organization makes illegal transitions visible.

Event-first reducers often hide state-specific logic inside large switch branches.

State-first reducers can be easier when the invariant is workflow-heavy.


26. Exhaustiveness Is an Invariant Tool

Use TypeScript exhaustiveness checks.

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

function renderContent(state: CaseDetailState) {
  switch (state.tag) {
    case 'idle':
      return <EmptyState />;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <CaseView data={state.data} />;
    case 'failure':
      return <ErrorView error={state.error} />;
    default:
      return assertNever(state);
  }
}

When a new state is added, the compiler forces rendering decisions.

That is better than silently showing an empty screen.


27. Invariants and Effects

Effects synchronize with external systems.

They should not be used to repair bad state shape.

Bad:

useEffect(() => {
  if (!open) {
    setPayload(null);
    setError(null);
  }
}, [open]);

The state shape allowed open=false with payload and error.

Better:

type DialogState =
  | { tag: 'closed' }
  | { tag: 'open'; payload: Payload }
  | { tag: 'failed'; payload: Payload; error: string };

Closing is a transition:

case 'closed':
  return { tag: 'closed' };

Effects should synchronize with external systems, not patch internal contradictions.


28. Invariants and Memoization

Memoization does not fix invariant bugs.

If a selector returns inconsistent data, useMemo can cache the inconsistency.

Bad:

const selectedCase = useMemo(
  () => cases.find((item) => item.id === selectedId),
  [selectedId],
);

cases is missing from dependencies.

The memoized value can violate the invariant.

Better:

const selectedCase = useMemo(
  () => cases.find((item) => item.id === selectedId) ?? null,
  [cases, selectedId],
);

Invariant:

selectedCase is derived from the latest cases and selectedId snapshot

Correct dependencies are part of correctness, not performance decoration.


29. Invariants and React Concurrent Rendering

Concurrent rendering makes purity and invariant shape more important.

Do not mutate external state during render.

Bad:

function CaseRow({ item }: Props) {
  selectedCaseRegistry.add(item.id); // mutation during render
  return <tr>{item.title}</tr>;
}

Render can be started, paused, restarted, or discarded.

Mutation during render can produce state that does not correspond to committed UI.

Keep render pure:

function CaseRow({ item, onVisible }: Props) {
  useEffect(() => {
    onVisible(item.id);
    return () => onVisible(null);
  }, [item.id, onVisible]);

  return <tr>{item.title}</tr>;
}

Even better, avoid this effect unless visibility genuinely synchronizes with an external system.


30. Invariant Failure Modes

Failure modeSymptomRoot causeFix
Boolean soupimpossible loading/error/data combosparallel flagsdiscriminated union
Stale payloadclosed modal reopens with old datapayload separate from open flagtagged modal state
Lost selectionselected row points to deleted entityreferential invariant missingclear or repair on delete
Async overwriteold response replaces newer resultsno request identityrequestId guard
Form error drifterror for old value remains visiblevalidation not versionedtie errors to value version
Server cache contradictionclient copy disagrees with cacheduplicated server statederive from cache or invalidate
Hidden repair effecteffect keeps syncing internal statewrong state shapemodel transition directly
Controlledness flipcomponent behavior changes after mountownership not fixeddev guard + API split
URL invalid stateinvalid page/sort crashes screenno parser/canonicalizerparse with defaults
Persistence zombieold localStorage breaks new UIno versioningschema version + migration

31. Testing Invariants

Invariant tests should focus on transitions, not DOM snapshots.

Example:

describe('case selection reducer', () => {
  it('clears selected id when selected case is removed', () => {
    const state = {
      selectedCaseId: 'c1',
      casesById: {
        c1: { id: 'c1', title: 'A' },
        c2: { id: 'c2', title: 'B' },
      },
    };

    const next = reducer(state, { type: 'caseRemoved', caseId: 'c1' });

    expect(next.selectedCaseId).toBeNull();
    expect(next.casesById.c1).toBeUndefined();
  });
});

Test illegal transitions:

it('ignores stale search result', () => {
  const loading = {
    tag: 'loading',
    query: 'abc',
    requestId: 'r3',
  } satisfies SearchState;

  const next = reducer(loading, {
    type: 'searchSucceeded',
    requestId: 'r1',
    results: [],
  });

  expect(next).toBe(loading);
});

Test property-like invariants when useful:

function expectValidSelection(state: CaseState) {
  if (state.selectedCaseId !== null) {
    expect(state.casesById[state.selectedCaseId]).toBeDefined();
  }
}

Then call after every transition in scenario tests.


32. Invariant Checklist

Before merging stateful UI code, answer:

What is the source of truth?
What states are legal?
What states are impossible?
Can illegal states be made unrepresentable?
Which transitions exist?
Which transitions are illegal?
What happens to dependent state when primary state changes?
Can async responses arrive out of order?
Can old closures commit stale data?
Can URL/persistence/server data violate assumptions?
Can component identity reset or preserve the wrong state?
Where are invariant tests?
What does the UI do if an invariant is breached in production?

If the answers are unclear, the implementation is probably not done.


33. Production Heuristics

Use these heuristics aggressively:

If two booleans cannot both be true, use a union.
If a field is only valid in one mode, put it inside that mode.
If an id references an entity, handle deletion in the same transition.
If async work can overlap, attach request identity.
If data can be derived, derive it.
If state must reset on identity change, use key intentionally.
If a prop controls ownership, warn on controlledness switch.
If a value comes from URL/storage/network, parse and validate it.
If an effect repairs state, redesign the state.

These are small rules.

They prevent large classes of bugs.


34. Summary

State invariants are the difference between a UI that works in the happy path and a UI that survives real product behavior.

The advanced mental model:

State is not a bag of values.
State is a set of legal configurations.
Events are not random callbacks.
Events are transition requests.
Reducers are not just update utilities.
Reducers preserve invariants.
Types are not decoration.
Types can make illegal states unrepresentable.
Effects are not repair loops.
Effects synchronize external systems.

The next part discusses State Migration and Refactoring: how to move state from local to lifted, reducer, context, URL, external store, or server-state cache without rewriting the application.

Lesson Recap

You just completed lesson 55 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.