---
title: 'Custom form fields'
description: 'Replace a supported form contribution while preserving form state.'
---

# Custom form fields

> **Release requirement:** This guide uses Studio extension APIs that are not present in npm `@flexkit/studio@0.0.31`. See [package availability](/docs/reference/compatibility#package-availability) before using the example.

A form contribution supplies a React component for a supported input key. For example, `contributes.formFields.text` replaces the text-field contribution used by attributes with `inputType: 'text'` in that project.



## Form contract

The exported `FormFieldProps` includes `control`, `fieldSchema`, `readOnly`, `setValue`, `getValues`, `scope`, `defaultScope`, entity identity, and the schema. The field value is a structured `FormFieldValue`, not just a scalar.

When updating a value, preserve the existing wrapper and replace its `value` property. Do not discard scope/default metadata. Respect both `readOnly` and the field's inherited/default state. Render an associated label, help text, and validation messages.

## Example: a text-field override

Install `react-hook-form` as a direct dependency compatible with your Studio release. This component preserves the field wrapper and uses the form controller to subscribe to changes:

```tsx filename="catalog-fields.tsx"
'use client';

import { useId } from 'react';
import { useController } from 'react-hook-form';
import { DefaultValueSwitch, type FormFieldProps, type FormFieldValue, type StudioExtension } from '@flexkit/studio';

function CatalogText({ control, fieldSchema, readOnly, setValue }: FormFieldProps) {
  const id = useId();
  const { field, fieldState } = useController({ name: fieldSchema.name, control });
  const current = field.value as FormFieldValue | undefined;
  return (
    <div>
      <label htmlFor={id}>{fieldSchema.label}</label>
      <input
        id={id}
        name={field.name}
        ref={field.ref}
        onBlur={field.onBlur}
        value={String(current?.value ?? '')}
        disabled={readOnly || current?.disabled}
        aria-invalid={Boolean(fieldState.error)}
        aria-describedby={fieldState.error ? `${id}-error` : undefined}
        onChange={(event) =>
          setValue(
            fieldSchema.name,
            { ...current, value: event.currentTarget.value },
            { shouldDirty: true, shouldValidate: true }
          )
        }
      />
      <DefaultValueSwitch
        checked={current?.disabled ?? false}
        disabled={readOnly}
        scope={current?.scope}
        onChange={(disabled) => setValue(fieldSchema.name, { ...current, disabled }, { shouldDirty: true })}
      />
      {fieldState.error ? <p id={`${id}-error`}>{fieldState.error.message}</p> : null}
    </div>
  );
}

export function CatalogFields(): StudioExtension {
  return {
    id: 'catalog-fields',
    contributes: {
      formFields: { text: { component: CatalogText } },
    },
  };
}
```

## Integration steps

1. Create the field component against the exported form types and your form library.
2. Register it under a supported contribution key.
3. Use the matching `inputType` in the entity schema.
4. Test create, existing edit, empty value, validation, read-only access, and non-default scopes.
5. Save and reopen a record to verify persistence.

An override of `text` affects other text attributes in that project. Inspect `fieldSchema` if the presentation should vary by attribute. Do not assume every arbitrary input key has a runtime renderer merely because extension configuration accepts a string key.

See [custom previews](/docs/extensions/custom-previews) for list rendering and [conditional fields](/docs/schema/conditional-fields) for schema-driven visibility.


---

[View full sitemap](/docs/sitemap.md)
