Vitus Labs
Attrs

Chain Methods

Complete reference for attrs chaining API — .attrs(), .config(), .compose(), .statics(), .getDefaultAttrs().

.attrs(param, config?)

Add default props to the component. Can be called multiple times — defaults stack left-to-right (later calls override earlier ones).

Signature

.attrs<P extends TObj | unknown = unknown>(
  param: Partial<Props> | ((props: Partial<Props>) => Partial<Props>),
  config?: {
    priority?: boolean
    filter?: string[]
  }
): AttrsComponent

Object Form

Static default props merged into the component:

const Button = attrs({ name: 'Button', component: Element })
  .attrs({ tag: 'button', className: 'btn' })
  .attrs({ alignX: 'center', alignY: 'center' })

Objects are internally normalized to callbacks: { tag: 'button' } becomes () => ({ tag: 'button' }).

Callback Form

Compute defaults dynamically based on current props:

const Button = attrs({ name: 'Button', component: Element })
  .attrs((props) => ({
    'aria-label': props.label || 'unnamed',
    'aria-disabled': props.disabled === true,
    tabIndex: props.disabled ? -1 : 0,
  }))

The callback receives merged props (priority attrs + earlier attrs + explicit props, with undefined values stripped).

Config Options

.attrs(param, {
  priority?: boolean,
  filter?: string[],
})
OptionDefaultDescription
priorityfalseWhen true, these attrs resolve first (lower precedence than normal attrs and explicit props)
filter[]Prop names to strip before reaching the underlying component

Priority Attrs

Priority attrs are resolved before normal attrs. They provide a baseline that can be overridden by both normal attrs and explicit props:

const Input = attrs({ name: 'Input', component: Element })
  .attrs({ tabIndex: 0 }, { priority: true })  // baseline
  .attrs({ tag: 'input' })                      // normal

// tabIndex=0 unless overridden by explicit props or normal attrs

Merge order:

  1. Priority attrs callbacks called with filtered explicit props
  2. Normal attrs callbacks called with { ...priorityResult, ...filteredExplicitProps }
  3. Final: { ...priorityResult, ...normalResult, ...filteredExplicitProps }

Filtered Props

Strip internal-only props that shouldn't reach the DOM:

const Card = attrs({ name: 'Card', component: Element })
  .attrs(
    (props) => ({
      className: props.variant === 'elevated' ? 'shadow-lg' : '',
    }),
    { filter: ['variant'] }  // variant won't reach Element
  )

<Card variant="elevated" />
// Element receives { className: 'shadow-lg' } — no variant prop

Filters accumulate across chain calls — each .attrs() with a filter adds to the list.

Type Extension

Use the generic parameter to extend the prop type:

const Button = attrs({ name: 'Button', component: Element })
  .attrs<{ variant: 'primary' | 'secondary' }>({
    variant: 'primary',
    tag: 'button',
  })
  .attrs<{ size?: 'sm' | 'md' | 'lg' }>({})

// TypeScript accumulates: Element props + { variant } + { size? }

Multiple Chains

Each .attrs() call appends to the chain. Later callbacks can reference values set by earlier ones:

const Button = attrs({ name: 'Button', component: Element })
  .attrs(() => ({ color: 'blue' }))
  .attrs(() => ({ size: 'lg' }))
  .attrs((props) => ({
    // Can access props.color and props.size from earlier chains
    className: `${props.color}-${props.size}`,
  }))

.config(options)

Update the component's configuration — name, base component, or debug mode.

.config({
  name?: string,
  component?: ElementType,
  DEBUG?: boolean,
}): AttrsComponent
ParameterTypeDescription
namestringUpdates displayName. If omitted, keeps existing name.
componentElementTypeReplaces the wrapped component. Props type (OA) re-extracted from new component.
DEBUGbooleanDebug flag (reserved for future use).

Rename

const Primary = Button.config({ name: 'PrimaryButton' })
// React DevTools shows "PrimaryButton"
// data-attrs="PrimaryButton" in development

Swap Component

Replace the underlying component while keeping all accumulated attrs, statics, and HOCs:

const BaseButton = attrs({ name: 'Button', component: Element })
  .attrs({ tag: 'button', alignX: 'center' })

const LinkButton = BaseButton.config({ component: AnchorElement })
// Renders AnchorElement with the same attrs chain

displayName Derivation

The component name is derived in priority order:

  1. options.name (explicit name from attrs() or .config())
  2. options.component.displayName (React standard)
  3. options.component.name (function/class name)

Empty Config

.config({}) returns a new component instance with identical behavior — useful for ensuring immutability.

.compose(hocMap)

Attach named Higher-Order Components. HOCs wrap the component in reverse declaration order.

.compose({
  [hocName: string]: GenericHoc | null | undefined | false,
}): AttrsComponent

Where GenericHoc = (component: ElementType) => ElementType.

Adding HOCs

const Button = attrs({ name: 'Button', component: Element })
  .compose({
    withTheme: (Component) => (props) => (
      <ThemeProvider><Component {...props} /></ThemeProvider>
    ),
    withTracking: trackingHoc,
  })

Removing HOCs

Set a HOC to null, undefined, or false to remove it:

const Untracked = Button.compose({ withTracking: false })
// withTracking removed, withTheme preserved
const Minimal = Button.compose({ withTracking: null, withTheme: undefined })
// Both removed

Overriding HOCs

Keys identify HOCs — later .compose() calls can replace earlier HOCs by key:

const Base = attrs({ name: 'Base', component: Element })
  .compose({ wrapper: withDivWrapper })

const Override = Base.compose({ wrapper: withSpanWrapper })
// 'wrapper' HOC replaced with withSpanWrapper

Application Order

HOCs are applied in reverse declaration order. The first-declared HOC becomes the outermost wrapper:

// Declaration: { a: hocA, b: hocB, c: hocC }
// Application: hocA(hocB(hocC(EnhancedComponent)))
// Outermost wrapper: hocA (runs first on render)
// Innermost wrapper: hocC (runs last, closest to component)

The built-in attrsHoc (resolves attrs/priorityAttrs) is always outermost — it runs before any user-composed HOCs.

Multiple .compose() Calls

const Button = attrs({ name: 'Button', component: Element })
  .compose({ withOuter: outerHoc })
  .compose({ withInner: innerHoc })
// Applied as: attrsHoc → withOuter → withInner → EnhancedComponent

.statics(metadata)

Attach metadata accessible via the .meta property.

.statics({
  [key: string]: any,
}): AttrsComponent

Usage

const Button = attrs({ name: 'Button', component: Element })
  .statics({
    category: 'action',
    sizes: ['sm', 'md', 'lg'],
    variants: ['primary', 'secondary', 'danger'],
  })

Button.meta.category   // 'action'
Button.meta.sizes      // ['sm', 'md', 'lg']

Chaining

Statics merge across chains (later values override):

const Enhanced = Button.statics({ variants: ['primary', 'ghost'] })

Enhanced.meta.category   // 'action' (inherited)
Enhanced.meta.variants   // ['primary', 'ghost'] (overridden)

Immutability

Each .statics() call creates a new component with its own meta object:

const A = attrs({ name: 'A', component: Element }).statics({ x: 1 })
const B = A.statics({ y: 2 })

A.meta  // { x: 1 }
B.meta  // { x: 1, y: 2 }

.getDefaultAttrs(props)

Compute the merged default props for given input props. This is a non-mutating query method — it doesn't render anything.

.getDefaultAttrs(props: Record<string, any>): Record<string, any>

Usage

const Button = attrs({ name: 'Button', component: Element })
  .attrs(() => ({ tag: 'button' }))
  .attrs((props) => ({
    className: props.disabled ? 'disabled' : 'enabled',
  }))

Button.getDefaultAttrs({ disabled: true })
// { tag: 'button', className: 'disabled' }

Button.getDefaultAttrs({})
// { tag: 'button', className: 'enabled' }

Behavior

  • Executes all normal attrs callbacks in chain order with the provided props
  • Merges results left-to-right (later callbacks override earlier ones)
  • Does NOT include priority attrs (only resolves the normal .attrs() chain)
  • Does NOT apply prop filtering
  • Does NOT include the provided props in the result — only returns what attrs callbacks produce
  • Returns {} if no attrs are defined

Component Properties

Each AttrsComponent exposes:

PropertyTypeDescription
IS_ATTRStrueStatic marker for runtime identification via isAttrsComponent()
displayNamestringComponent name for React DevTools
metaRecord<string, any>User-defined statics from .statics() chains
$$originTypesType onlyOriginal component props type (not accessible at runtime)
$$extendedTypesType onlyExtended props from .attrs() calls (not accessible at runtime)
$$typesType onlyAll combined props: MergeTypes<[OA, EA]> (not accessible at runtime)

Ref Forwarding

Refs are forwarded through the entire HOC chain:

const Button = attrs({ name: 'Button', component: Element })
  .compose({ withFeature: someHoc })

const ref = useRef(null)
<Button ref={ref}>Click</Button>
// ref.current points to the DOM node through all HOC layers

Ref Merging

The system manages two ref sources:

  • $attrsRef — The consumer's ref (captured by attrsHoc)
  • ref — Intermediate HOC refs (from compose wrappers that use forwardRef)

Both are bundled into a single useRef({ $attrsRef, ref }) object inside attrs.tsx and forwarded to the inner render component as a single ref. There's no useImperativeHandle — both ref sources sit on one ref object, so they always agree on the underlying DOM node.

API Reference

ConfigAttrs

Prop

Type

AttrsCb

Prop

Type

ComposeParam

Prop

Type

On this page