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.
Learn more!
// 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>
)
}
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,
},
},
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.
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.
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
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
andonFocus
. These are conveniently accessible through the spreadable propertyelementProps
. - While we’d probably be doing fine without it in this lightweight example, it’s always advisable to wrap your
onChange
handler inuseCallback
for performance.
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
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 array
, FieldMember
and MemberField
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; ObjectInputProps
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>
</>
)
}
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>
</>
)
}
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>
</>
)
}