Skip to content

ConditionalField

Paulo Gomes da Cruz Junior edited this page Jun 16, 2026 · 4 revisions

ConditionalField

The ConditionalField component declaratively shows or hides — and optionally resets — a field or group of fields based on the runtime value of another form field. It integrates directly with TanStack Form and requires no additional store or wiring beyond the form instance already used on the page.

Source: src/components/Form/ConditionalField/

Key Features

  • Declarative conditions: One condition or an array of conditions can be supplied. Arrays are combined with configurable AND/OR logic.
  • Nine comparison operators: equals, not-equals, contains, greater-than, less-than, truthy, falsy, in, not-in.
  • Selective re-render: Uses useSyncExternalStore with a JSON-serialised snapshot so only changes to the watched fields trigger a re-render. Unrelated form mutations are ignored.
  • Field reset on hide (unregisterOnHide): Calls form.resetField for each name in fieldNames when the group transitions from visible → hidden. Keeps form state clean.
  • Keep-mounted mode (keepMounted): Children stay in the DOM but are visually hidden with CSS (visibility: hidden, height: 0, opacity: 0). Field state is preserved in TanStack Form.
  • CSS transition (animateIn): Applies a grid-template-rows + opacity transition when the group becomes visible. The grid technique (0fr → 1fr) is used instead of max-height because max-height: 0 → auto is not animatable by CSS.
  • TypeScript generics: TFormData is inferred from the form prop so condition field names are not validated at compile time, but the FormInstance interface ensures the shape is always compatible without requiring casts.

Props

Name Type Default Description
form FormInstance<TFormData> — The TanStack Form instance returned by useForm.
conditions Condition | Condition[] — One condition or an array of conditions that control visibility.
logic 'AND' | 'OR' 'AND' How multiple conditions are combined. AND requires every condition to pass.
children ReactNode — Content to show or hide.
keepMounted boolean false When true, children remain in the DOM but are visually hidden. Preserves TanStack Form state.
animateIn boolean true Applies a CSS grid-template-rows + opacity transition when the group becomes visible.
unregisterOnHide boolean true When true, resets fieldNames fields to their defaults when the group becomes hidden.
fieldNames Array<DeepKeys<TFormData>> [] Names of fields to reset on hide when unregisterOnHide is true.

Condition shape

interface Condition {
  field: string;               // top-level key or dot-separated nested path (e.g. 'address.city')
  operator: ConditionOperator; // comparison to apply
  value?: unknown;             // required for all operators except 'truthy' / 'falsy'
}

Supported operators

Operator Semantics
equals Strict equality (===)
not-equals Strict inequality (!==)
contains String.includes or Array.includes
greater-than Numeric >
less-than Numeric <
truthy Boolean(value)
falsy !value
in Watched value is inside the value array
not-in Watched value is not inside the value array

Usage

Basic example — show a field when another is truthy

import { ConditionalField } from '@/components/Form/ConditionalField';

<form.Field name="hasDistrict">
  {(field) => (
    <Toggle
      id="has-district"
      labelText="Assign to district"
      toggled={Boolean(field.state.value)}
      onToggle={(val) => field.handleChange(val)}
    />
  )}
</form.Field>

<ConditionalField
  form={form}
  conditions={{ field: 'hasDistrict', operator: 'truthy' }}
  fieldNames={['districtCode']}
>
  <form.Field name="districtCode">
    {(field) => (
      <ComboBox
        id="district-code"
        titleText="District"
        items={districts}
        onChange={({ selectedItem }) => field.handleChange(selectedItem?.code ?? null)}
      />
    )}
  </form.Field>
</ConditionalField>

AND logic — show only when two conditions both pass

<ConditionalField
  form={form}
  conditions={[
    { field: 'status', operator: 'equals', value: 'active' },
    { field: 'clientNumber', operator: 'truthy' },
  ]}
  logic="AND"
  fieldNames={['samplingCode']}
>
  <form.Field name="samplingCode">
    {(field) => <TextInput labelText="Sampling Code" value={field.state.value ?? ''} onChange={(e) => field.handleChange(e.target.value)} />}
  </form.Field>
</ConditionalField>

OR logic — show when either condition passes

<ConditionalField
  form={form}
  conditions={[
    { field: 'type', operator: 'equals', value: 'special' },
    { field: 'overrideFlag', operator: 'truthy' },
  ]}
  logic="OR"
>
  <p>Special handling required.</p>
</ConditionalField>

Keep-mounted — preserve field state while hidden

Use keepMounted when TanStack Form should retain the field's value even when the section is not visible. Children are hidden via CSS rather than being removed from the DOM.

<ConditionalField
  form={form}
  conditions={{ field: 'isAdvanced', operator: 'truthy' }}
  keepMounted
  unregisterOnHide={false}
>
  <form.Field name="advancedOption">
    {(field) => <TextInput labelText="Advanced Option" value={field.state.value ?? ''} onChange={(e) => field.handleChange(e.target.value)} />}
  </form.Field>
</ConditionalField>

in operator — show for a set of allowed values

<ConditionalField
  form={form}
  conditions={{ field: 'category', operator: 'in', value: ['A', 'B', 'C'] }}
>
  <p>Category A, B, or C specific content.</p>
</ConditionalField>

Behaviour Details

Visibility evaluation

On every form-store change, the hook serialises only the watched field values to a JSON string. Because useSyncExternalStore compares the snapshot with Object.is, a re-render is skipped when the serialised string is unchanged — even if other form values have changed. undefined field values are normalised to null before serialisation so that fields that have never been touched behave identically to explicitly null ones.

Nested field paths

Condition field names support dot-separated paths for nested form structures (e.g. 'address.city'). The hook uses an internal getIn(obj, path) helper to traverse the form store's value tree safely. If any segment along the path is missing, null, or not an object, getIn returns undefined, which normalises to null in the snapshot — meaning the condition will not pass until the nested value is set.

Field reset (unregisterOnHide)

When unregisterOnHide is true (the default) and the group transitions from visible → hidden, form.resetField(name) is called once for every name in fieldNames. This removes stale data from the form state before submission, which is the correct behaviour for the majority of conditional-field use cases.

To suppress reset behaviour — for example when the field should retain its value if the user re-shows the section — set unregisterOnHide={false}.

keepMounted vs unmount

Behaviour Default (keepMounted={false}) keepMounted={true}
Hidden DOM state Children removed from DOM Children in DOM, aria-hidden="true"
TanStack Form state Field state cleared on each hide Field state preserved
unregisterOnHide Resets fields to defaults (if true) Resets fields to defaults (if true)
Transition No enter transition (element re-mounts) Smooth enter/exit transition (CSS)

Implementation Notes

  • FormInstance<TFormData> interface — A structural interface is used instead of ReturnType<typeof useForm<TFormData>> because TypeScript cannot check callable constraints on instantiation expressions with free type parameters. The actual ReactFormExtendedApi return type is a structural superset of FormInstance, so no explicit cast is needed at call sites.
  • Memoisation — The inner component is wrapped in React.memo to avoid re-renders from parent updates that do not change props.
  • useSyncExternalStore — The hook subscribes directly to the TanStack Form store with a primitive string snapshot selector. This approach is React 18 concurrent-safe and avoids the tearing issues that arise with manual useEffect-based subscriptions.
  • getIn path resolver — A small exported helper traverses nested objects by dot-separated path string, returning undefined (rather than throwing) when any segment is absent. Used by the snapshot selector to resolve both top-level and nested condition fields.
  • Condition array stability — The conditions array is JSON-serialised as a dependency so that passing a new array literal on each render does not trigger unnecessary recomputes.

Known Constraints

  • field names in conditions are typed as string, not DeepKeys<TFormData>. TypeScript does not enforce that a condition field actually exists in the form's data shape. Mistyped field names will silently produce null watched values (behaving as falsy). Dot-separated paths (e.g. 'address.city') are supported and resolved via the getIn helper.
  • An empty conditions array ([]) always evaluates to false regardless of the logic prop — a group with no rules is treated as permanently hidden. This is intentional to prevent a vacuous-truth [].every() from showing fields unexpectedly.
  • The contains operator returns false for non-string, non-array watched values (e.g. numbers). This is intentional to avoid unexpected implicit coercions.
  • greater-than and less-than operators require both the watched value and the condition value to be numbers; all other types return false.
  • CSS transitions on show work only when keepMounted={true}. The default unmount/remount path re-attaches the element, bypassing the grid-template-rows enter transition.

Test Coverage

All files achieve 100% statement, branch, function, and line coverage:

  • evaluateCondition — all nine operators covered including string, array, numeric, boolean, and invalid-type edge cases.
  • evaluateAll — AND and OR logic tested against partial and full condition satisfaction; empty array ([]) returns false for both logic modes.
  • getIn — top-level key, multi-segment nested path, missing segment, null intermediate, and non-object intermediate all covered.
  • Component integration — visibility toggling, keepMounted DOM presence, aria-hidden management, unregisterOnHide reset callbacks, animateIn class toggling, AND/OR multi-condition switching.

Change Impact

See Also

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally