😎 Discover cool tips and tricks for customization in our next Developer Deep Dive virtual event - sign up now!

Migrating Custom Input Components

The tooling for custom input components has been a key focus in the v3 update to Sanity Studio. Much of the boilerplate and setup that went into writing custom inputs in v2 has been done away with entirely, and robust new tools and utilities have been added to make the development process of creating delightful editorial experiences a delightful experience in itself.

Minimal example

// CustomStringInput.tsx
import {useCallback} from 'react'
import {Box, Stack, Text, TextInput} from '@sanity/ui'
import {StringInputProps, set, unset} from 'sanity'

export function CustomStringInput(props: StringInputProps) {
  const {onChange, value = '', id, focusRef, onBlur, onFocus, readOnly } = props

	// ⬇ We aren't doing anything with these except forwarding them to our input.
  const fwdProps = {id, ref: focusRef, onBlur, onFocus, readOnly};
  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) =>
      onChange(event.currentTarget.value ? set(event.currentTarget.value) : unset()),
    [onChange]
  )
  return (
    <Stack space={3}>
      <TextInput {...fwdProps} onChange={handleChange} value={value} />
      <Text muted size={1}>
        Words: {value?.split(' ').length || 0}, Characters: {value?.length || 0}
      </Text>
    </Stack>
  )
}

What’s changed?

Goodbye inputComponent, hello components.input!

Instead of a single top-level inputComponent property, all fields now accept a components object where you can define separate components for input, field, item, and preview.

Together these give you very granular control over how your fields are rendered in the studio under different circumstances, and each of the options will be discussed in detail in their own forthcoming articles – for this article we’ll focus on the input option!

In v2, you would do:

// schemas/post.js
{
   name: 'title',
   title: 'Title',
   type: 'string',
   inputComponent: MyStringInput,
},

In v3, the equivalent of that is:

// schemas/post.ts
{
  name: 'title',
  title: 'Title',
  type: 'string',
  components: {
    input: MyStringInput,
  },
},

No more FormField. No more PatchEvent.

The most immediately noticeable difference from v2 is that you no longer have to wrap your custom inputs with a FormField component and write copious lines of boilerplate code just to make sure the studio can still recognize your field as just that – a field – and make sure it gets all the bells and whistles a field in the studio comes with, such as focus-management, presence indicators and more. The v3 custom input component API strips all that stuff away and lets you focus on the actual input component itself.

Example 1: Custom string input

Let’s compare the code needed to render a simple string field with some minimal extra functionality for showing a word and character count in v2 and v3.

Custom string input – v2

In v2 you are responsible for ensuring all the basic functionality the studio affords to any field is passed on to your custom input component. This is done by importing the FormField component from @sanity/base/components and wrapping your input with it, passing the appropriate props to either the wrapper or to the actual input. Also, note the PatchEvent helper imported from @sanity/form-builder/PatchEvent along with the set and unset methods for updating the content lake.

// CustomStringInput.js
import React from 'react'
import {FormField} from '@sanity/base/components'
import {Stack, Text, TextInput} from '@sanity/ui'
import PatchEvent, {set, unset} from '@sanity/form-builder/PatchEvent'
import {useId} from '@reach/auto-id' // hook to generate unique IDs

const CustomStringInput = React.forwardRef((props, ref) => {
  const {
    type, // Schema information
    value, // Current field value
    readOnly, // Boolean if field is not editable
    placeholder, // Placeholder text from the schema
    markers, // Markers including validation rules
    presence, // Presence information for collaborative avatars
    onFocus, // Method to handle focus state
    onBlur, // Method to handle blur state
    onChange, // Method to handle patch events
  } = props

  // Creates a unique ID for our input
  const inputId = useId()

  // Creates a change handler for patching data
  const handleChange = React.useCallback(
    // useCallback will help with performance
    (event) => {
      const inputValue = event.currentTarget.value // get current value
      // if the value exists, set the data, if not, unset the data
      onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()))
    },
    [onChange]
  )

  return (
    <FormField
      description={type.description} // Creates description from schema
      title={type.title} // Creates label from schema title
      __unstable_markers={markers} // Handles all markers including validation
      __unstable_presence={presence} // Handles presence avatars
      inputId={inputId} // Allows the label to connect to the input field
    >
      <Stack>
        <TextInput
          id={inputId} // A unique ID for this input
          onChange={handleChange} // A function to call when the input value changes
          value={value || ''} // Current field value
          readOnly={readOnly} // If "readOnly" is defined make this field read only
          placeholder={placeholder} // If placeholder is defined, display placeholder text
          onFocus={onFocus} // Handles focus events
          onBlur={onBlur} // Handles blur events
          ref={ref}
        />
        <Text size={1}>Characters: {value?.length || 0}</Text>
      </Stack>
    </FormField>
  )
})

// Create the default export to import into our schema
export default CustomStringInput

Custom string input – v3

When you provide a custom component to any field’s components.input property in v3, the studio will handle the boilerplate necessary for making your field feel native and let you focus on the input widget itself. You no longer have to handle all the details of rendering titles and descriptions, presence indicators, or focus orders for your fields.

Protip

Pro tip!

If you do want to control the rendering of the entire field like in v2, you should use the components.field property. See also: Field Components!

The tools, types, and helper functions you are most likely to need are now made available in a single place in the sanity/form-package. You might recognize set and unset from the v2 example – these convenience methods help create the patch object that will be passed to the mutation API. Note that you no longer need to wrap your incoming change events in the PatchEvent component as in v2.

// CustomStringInput.tsx
import {useCallback} from 'react'
import {Box, Stack, Text, TextInput} from '@sanity/ui'
import {StringInputProps, set, unset} from 'sanity'

export function CustomStringInput(props: StringInputProps) {
  const {onChange, value = '', elementProps } = props
  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) =>
      onChange(event.currentTarget.value ? set(event.currentTarget.value) : unset()),
    [onChange]
  )
  return (
    <Stack space={3}>
      <TextInput {...elementProps} onChange={handleChange} value={value} />
      <Text size={1}>Characters: {value?.length || 0}</Text>
    </Stack>
  )
}
  • There are still some props that need to be forwarded down to your input widget to keep your studio accessible and performant, such as focusRef and onFocus. These are conveniently accessible through the spreadable property elementProps.
  • While we’d probably be doing fine without it in this lightweight example, it’s always advisable to wrap your onChange handler in useCallback for performance.

Example 2: Custom object input

The schema types you use to build your Sanity documents are all based on 5 kinds of core value types: object, array, string, number, and boolean. Every other type is constructed from these. These core value types can be further divided into primitive and composite types.

The object and array types are composite types – types that are composed of primitives. In v3 you have access to typings and helper components to facilitate rendering more complex inputs. In the following example we show how to use FieldMember, MemberField and ObjectInputProps to render a custom object input component for a basic schema that we’ll use to remember tips we get from friends and enemies about media we should check out. Our new custom object will have two string fields; mediaTitle and mediaType. The title field should be a standard text input, and the type field should show a dropdown list of predefined options. To make the example slightly more interesting we’ll add a condition that makes the mediaTitle input available only if the mediaType field has been set.

// schemas/test.ts
import MediaTipInput from '../components/MediaTipInput';

export default {
  name: 'test',
  title: 'Test',
  type: 'document',
  fields: [
    {
      type: 'object',
      name: 'mediaTip',
      title: 'Media Tip',
      description: 'Check this out later!',
      components: {
        input: MediaTipInput,
      },
      // Even though we are making a custom input,
      // it is necessary to define the fields of our object
      fields: [
        {
          type: 'string',
          name: 'mediaTitle',
          title: 'Title',
        },
        {
          type: 'string',
          name: 'mediaType',
          title: 'Media Type',
          options: {
            list: ['Movie', 'Book', 'TV Show', 'Album', 'Podcast', 'Video Game'],
          },
        },
      ],
    },
  ],
}
// MediaTipInput.tsx
import {Card, Flex, Grid, Text} from '@sanity/ui'
import {FieldMember, MemberField, ObjectInputProps} from 'sanity'

export interface MediaTip {
  mediaType: string
  mediaTitle: string
}

// Extend the `ObjectInputProps` type
export type MediaTipInputProps = ObjectInputProps<MediaTip>

export function MediaTipInput(props: MediaTipInputProps) {
  const {value, members, renderField, renderInput, renderItem} = props

  // find "mediaTitle" member
  const mediaTitleMember = members.find(
    (member): member is FieldMember => member.kind === 'field' && member.name === 'mediaTitle'
  )
  // find "mediaType" member
  const mediaTypeMember = members.find(
    (member): member is FieldMember => member.kind === 'field' && member.name === 'mediaType'
  )

  return (
    <>
      <Grid columns={2} gap={3}>
        {mediaTypeMember && (
          <MemberField
            member={mediaTypeMember}
            renderInput={renderInput}
            renderField={renderField}
            renderItem={renderItem}
          />
        )}
        {/* Only show the title input if media type is set */}
        {value?.mediaType ? (
          <MemberField
            member={mediaTitleMember}
            renderInput={renderInput}
            renderField={renderField}
            renderItem={renderItem}
          />
        ) : (
          <Card tone="caution" radius={4}>
            <Flex height="fill" direction="column" justify="center" align="center">
              <Text>Select media type first</Text>
            </Flex>
          </Card>
        )}
      </Grid>
    </>
  )
}

Overriding the renderInput function

The render functions that are made available through the extended InputProps can be overridden to customize your input component further. In the following example, we’ll add some embellishments to the default render function for the mediaTitle input to show the current value of the mediaType field.

// MediaTipInput.tsx
import {useCallback} from 'react'
import {Card, Flex, Grid, Stack, Text} from '@sanity/ui'
import {FieldMember, MemberField, ObjectInputProps, InputProps} from 'sanity'

export interface MediaTip {
  mediaType: string
  mediaTitle: string
}

// Extend the `ObjectInputProps` type
export type MediaTipInputProps = ObjectInputProps<MediaTip>

export function MediaTipInput(props: MediaTipInputProps) {
  const {value, members, renderField, renderInput, renderItem} = props

  // find "mediaTitle" member
  const mediaTitleMember = members.find(
    (member): member is FieldMember => member.kind === 'field' && member.name === 'mediaTitle'
  )
  // find "mediaType" member
  const mediaTypeMember = members.find(
    (member): member is FieldMember => member.kind === 'field' && member.name === 'mediaType'
  )

  // Define a custom renderInput function
  const customRenderInput = useCallback(
    (renderInputCallbackProps: InputProps) => {
      // Add a label showing the current value of 'mediaType'
      return (
        <Stack>
        {/* Call the original renderInput function, passing along input props */}
          <Card>{renderInput(renderInputCallbackProps)}</Card>
          <Flex paddingTop={2} justify="flex-end">
             <Text size={1} muted>
              <em>{value?.mediaType && `Type: ${value.mediaType}`}</em>
            </Text>
          </Flex>
        </Stack>
      )
    },
    [renderInput, value?.mediaType]
  )

  return (
    <>
      <Grid columns={2} gap={3}>
        {mediaTypeMember && (
          <MemberField
            member={mediaTypeMember}
            renderInput={renderInput}
            renderField={renderField}
            renderItem={renderItem}
          />
        )}
        {/* Only show the title input if media type is set */}
        {value?.mediaType ? (
          <MemberField
            member={mediaTitleMember}
            renderInput={customRenderInput}
            renderField={renderField}
            renderItem={renderItem}
          />
        ) : (
          <Card tone="caution" radius={4}>
            <Flex height="fill" direction="column" justify="center" align="center">
              <Text>Select media type first</Text>
            </Flex>
          </Card>
        )}
      </Grid>
    </>
  )
}

Composing inputs – a custom string input inside our custom object input

Using the same method as in the previous example, we can compose our input components to add even more functionality. In the following example we’ll use the custom string input from the first example in this article to render a word and character count for our mediaTitle field, while still retaining the custom markup we added in the previous step.

// MediaTipInput.tsx
import {useCallback} from 'react'
import {Card, Flex, Grid, Stack, Text} from '@sanity/ui'
import {FieldMember, MemberField, ObjectInputProps, InputProps, StringInputProps} from 'sanity'
import CustomStringInput from './CustomStringInput'

export interface MediaTip {
  mediaType: string
  mediaTitle: string
}

// Extend the `ObjectInputProps` type
export type MediaTipInputProps = ObjectInputProps<MediaTip>

export function MediaTipInput(props: MediaTipInputProps) {
  const {value, members, renderField, renderInput, renderItem} = props

  // find "mediaTitle" member
  const mediaTitleMember = members.find(
    (member): member is FieldMember => member.kind === 'field' && member.name === 'mediaTitle'
  )
  // find "mediaType" member
  const mediaTypeMember = members.find(
    (member): member is FieldMember => member.kind === 'field' && member.name === 'mediaType'
  )

  // Define a custom renderInput function
  const customRenderInput = useCallback(
    (renderInputCallbackProps: InputProps) => {
      // Add a label showing the current value of 'mediaType'
      return (
        <Stack>
          {/* Call the original renderInput function, passing along input props */}
          <CustomStringInput {...(renderInputCallbackProps as StringInputProps)} />
          <Flex paddingTop={2} justify="flex-end">
            <Text size={1} muted>
              <em>({value?.mediaType && `Type: ${value.mediaType}`})</em>
            </Text>
          </Flex>
        </Stack>
      )
    },
    [renderInput, value?.mediaType]
  )

  return (
    <>
      <Grid columns={2} gap={3}>
        {mediaTypeMember && (
          <MemberField
            member={mediaTypeMember}
            renderInput={renderInput}
            renderField={renderField}
            renderItem={renderItem}
          />
        )}
        {/* Only show the title input if media type is set */}
        {value?.mediaType ? (
          <MemberField
            member={mediaTitleMember}
            renderInput={customRenderInput}
            renderField={renderField}
            renderItem={renderItem}
          />
        ) : (
          <Card tone="caution" radius={4}>
            <Flex height="fill" direction="column" justify="center" align="center">
              <Text>Select media type first</Text>
            </Flex>
          </Card>
        )}
      </Grid>
    </>
  )
}

Was this article helpful?