How to Asynchronously Populate List Options in the Sanity Studio
Ever wanted to hit a 3rd party API to populate a drop down list in your Studio? Well, strap in because we're going to leverage the power of Custom Input Components to boldly go where no list has gone before.
Warning
This guide contains code examples for an older version of Sanity Studio (v2), which is deprecated.
Learn how to migrate to the new Studio v3 βTo get started, let's take a look at a vanilla list in the Studio and try to understand why we need to use a Custom Input Component:
export default {
name: 'cat',
title: 'Cat',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string'
},
{
name: 'image',
title: 'Image',
type: 'image'
},
{
name: 'breed',
title: 'Breed',
type: 'string',
options: {
list: []
}
}
]
}
This will give you an empty select in your Studio:
Now, according to The International Cat Association, there are 71 standard breeds of cat. Hardcoding each of those breeds into our options.list
would be a boring chore. So what are our options? Well, we could copy/paste a bunch of items, but what happens if someone announces a brand new breed of cat and we want the option to create content for it immediately? Luckily, we're engineers, so we can use catfact.ninja's API to provide us with the data we need.
Let's create a function that will fetch our cat data and see if we can get our list items to show up in the Studio:
const getCatBreeds = async () => {
const catBreeds = await fetch('https://catfact.ninja/breeds')
.then(res => res.json())
.then(json => json.data.map(cat => ({
title: cat.breed,
value: cat.breed.toLowerCase().split(' ').join('-')
})))
return catBreeds
}
export default {
name: 'cat',
title: 'Cat',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string'
},
{
name: 'image',
title: 'Image',
type: 'image'
},
{
name: 'breed',
title: 'Breed',
type: 'string',
options: {
list: getCatBreeds()
}
}
]
}
Your Desk tool crashed, right?
Let's test if our function is working. Add a console.log(catBreeds)
before your return in your getCatBreeds
function and refresh. The Desk will still crash but our cat breeds do show up in the console, so the unfortunate truth is the list
option cannot currently handle an async function. We'll need to add a Custom Input Component to this schema to handle it!
Custom Input Components are daunting when you first encounter them. There are a number of integral pieces you need to include for them to interact with your Content Lake properly, as well as fit in with the Studio's UI and accessibility features.
Fun fact: most of those pieces are boilerplate items that can be copied and pasted from this guide. But I'm getting ahead of myself... let's start by getting our schema ready for a custom input and creating a component called AsyncSelect.js
.
import AsyncSelect from "./inputs/AsyncSelect"
export default {
name: 'cat',
title: 'Cat',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string'
},
{
name: 'image',
title: 'Image',
type: 'image'
},
{
name: 'breed',
title: 'Breed',
type: 'string',
inputComponent: AsyncSelect
}
]
}
// ./inputs/AsyncSelect.js
import React from 'react'
const AsyncSelect = () => {
return (
<div>
Our cats will go here!
</div>
)
}
export default AsyncSelect
Since the Studio is a React app, we can create a simple component like this, refresh the page, and see it rendered inside of our form.
Now we can start to build out the functionality of our component.
import React, { useState, useEffect } from 'react'
import { Card, Stack, Select } from '@sanity/ui'
const AsyncSelect = () => {
const [breeds, setBreeds] = useState([])
useEffect(() => {
const getCatBreeds = async () => {
const catBreeds = await fetch('https://catfact.ninja/breeds')
.then(res => res.json())
.then(json => json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
})))
setBreeds(catBreeds)
}
getCatBreeds()
}, [])
return (
<Card padding={0}>
<Stack>
<Select
fontSize={2}
padding={[3, 3, 4]}
space={[3, 3, 4]}
>
<option value={''}>---</option>
{breeds.map(({ title, value }) => (
<option
key={value}
value={value}
>
{title}
</option>
))}
</Select>
</Stack>
</Card>
)
}
export default AsyncSelect
There's a lot going on here, so let's break it down.
First, we imported the useState
and useEffect
hooks from React and moved our getCatBreeds
function inside of our useEffect
so that it runs when our component mounts. Notice that we set our initial state to an empty array. This will prevent the Studio from crashing while we wait for our cat promise to resolve and our state to be updated.
Next, we replaced our simple div
with a Select component from the @sanity/ui
package that comes with the Studio.
Finally, we mapped over our array of cat breeds and returned an option
element for each of them, leaving us with a drop down list of cat breeds from which we can select.
This looks really slick, but our component still has no way of writing our selection to our document.
This step is going to give your component access to the Studio's Presence feature and give it the ability to write your selection to your document.
Protip
You'll be adding these next pieces to any custom component you make, so I highly recommend reading through the official guide on Custom Input Components for a better understanding of the concepts.
To start, we wrap our entire component in React.forwardRef
and pass in props
and ref
.
import React, { useState, useEffect } from 'react'
import { Card, Stack, Select } from '@sanity/ui'
const AsyncSelect = React.forwardRef((props, ref) => {
const [breeds, setBreeds] = useState([])
const {
type, // Schema information
value, // Current field value
readOnly, // Boolean if field is not editable
markers, // Markers including validation rules
presence, // Presence information for collaborative avatars
compareValue, // Value to check for "edited" functionality
onFocus, // Method to handle focus state
onBlur, // Method to handle blur state
onChange // Method to handle patch events
} = props
useEffect(() => {
const getCatBreeds = async () => {
const catBreeds = await fetch('https://catfact.ninja/breeds')
.then(res => res.json())
.then(json => json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
})))
setBreeds(catBreeds)
}
getCatBreeds()
}, [])
return (
<Card padding={0}>
<Stack>
<Select
fontSize={2}
padding={[3, 3, 4]}
space={[3, 3, 4]}
>
<option value={''}>---</option>
{breeds.map(({ title, value }) => (
<option
key={value}
value={value}
>
{title}
</option>
))}
</Select>
</Stack>
</Card>
)
})
export default AsyncSelect
Now we make use of all of those handy Studio features we received in our props by adding them to a FormField
and our existing elements.
import React, { useState, useEffect } from 'react'
import { Card, Stack, Select } from '@sanity/ui'
import { FormField } from '@sanity/base/components'
const AsyncSelect = React.forwardRef((props, ref) => {
const [breeds, setBreeds] = useState([])
const {
type, // Schema information
value, // Current field value
readOnly, // Boolean if field is not editable
markers, // Markers including validation rules
presence, // Presence information for collaborative avatars
compareValue, // Value to check for "edited" functionality
onFocus, // Method to handle focus state
onBlur, // Method to handle blur state
onChange // Method to handle patch events
} = props
useEffect(() => {
const getCatBreeds = async () => {
const catBreeds = await fetch('https://catfact.ninja/breeds')
.then(res => res.json())
.then(json => json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
})))
setBreeds(catBreeds)
}
getCatBreeds()
}, [])
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
compareValue={compareValue} // Handles "edited" status
>
<Card padding={0}>
<Stack>
<Select
fontSize={2}
padding={[3, 3, 4]}
space={[3, 3, 4]}
value={value} // Current field value
readOnly={readOnly} // If "readOnly" is defined make this field read only
onFocus={onFocus} // Handles focus events
onBlur={onBlur} // Handles blur events
ref={ref}
>
<option value={''}>---</option>
{breeds.map(({ title, value }) => (
<option
key={value}
value={value}
>
{title}
</option>
))}
</Select>
</Stack>
</Card>
</FormField>
)
})
export default AsyncSelect
Despite all of these changes, our component still isn't able to write any data to our document. We'll need to use the Studio's PatchEvent
to set
and unset
the field's value.
import React, { useState, useEffect } from 'react'
import { Card, Stack, Select } from '@sanity/ui'
import { FormField } from '@sanity/base/components'
import PatchEvent, {set, unset} from '@sanity/form-builder/PatchEvent'
const AsyncSelect = React.forwardRef((props, ref) => {
const [breeds, setBreeds] = useState([])
const {
type, // Schema information
value, // Current field value
readOnly, // Boolean if field is not editable
markers, // Markers including validation rules
presence, // Presence information for collaborative avatars
compareValue, // Value to check for "edited" functionality
onFocus, // Method to handle focus state
onBlur, // Method to handle blur state
onChange // Method to handle patch events
} = props
// 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]
)
useEffect(() => {
const getCatBreeds = async () => {
const catBreeds = await fetch('https://catfact.ninja/breeds')
.then(res => res.json())
.then(json => json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
})))
setBreeds(catBreeds)
}
getCatBreeds()
}, [])
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
compareValue={compareValue} // Handles "edited" status
>
<Card padding={0}>
<Stack>
<Select
fontSize={2}
padding={[3, 3, 4]}
space={[3, 3, 4]}
value={value} // Current field value
readOnly={readOnly} // If "readOnly" is defined make this field read only
onFocus={onFocus} // Handles focus events
onBlur={onBlur} // Handles blur events
ref={ref}
onChange={handleChange} // A function to call when the input value changes
>
<option value={''}>---</option>
{breeds.map(({ title, value }) => (
<option
key={value}
value={value}
>
{title}
</option>
))}
</Select>
</Stack>
</Card>
</FormField>
)
})
export default AsyncSelect
Now if we select a breed and inspect our document, you'll see that the breed
field updates to reflect our choice!
One last step we need to take to finish off our functionality is to install @reach/auto-id
to make our field labels accessible.
import React, { useState, useEffect } from 'react'
import { Card, Stack, Select } from '@sanity/ui'
import { FormField } from '@sanity/base/components'
import PatchEvent, {set, unset} from '@sanity/form-builder/PatchEvent'
import { useId } from "@reach/auto-id"
const AsyncSelect = React.forwardRef((props, ref) => {
const [breeds, setBreeds] = useState([])
const {
type, // Schema information
value, // Current field value
readOnly, // Boolean if field is not editable
markers, // Markers including validation rules
presence, // Presence information for collaborative avatars
compareValue, // Value to check for "edited" functionality
onFocus, // Method to handle focus state
onBlur, // Method to handle blur state
onChange // Method to handle patch events
} = props
// 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]
)
const inputId = useId()
useEffect(() => {
const getCatBreeds = async () => {
const catBreeds = await fetch('https://catfact.ninja/breeds')
.then(res => res.json())
.then(json => json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
})))
setBreeds(catBreeds)
}
getCatBreeds()
}, [])
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
compareValue={compareValue} // Handles "edited" status
inputId={inputId} // Allows the label to connect to the input field
>
<Card padding={0}>
<Stack>
<Select
id={inputId} // A unique ID for this input
fontSize={2}
padding={[3, 3, 4]}
space={[3, 3, 4]}
value={value} // Current field value
readOnly={readOnly} // If "readOnly" is defined make this field read only
onFocus={onFocus} // Handles focus events
onBlur={onBlur} // Handles blur events
ref={ref}
onChange={handleChange} // A function to call when the input value changes
>
<option value={''}>---</option>
{breeds.map(({ title, value }) => (
<option
key={value}
value={value}
>
{title}
</option>
))}
</Select>
</Stack>
</Card>
</FormField>
)
})
export default AsyncSelect
Cats are great and all, but it would be nice if we could adapt this component to handle any url and the data it returns. Let's use our schema's options
to pass in a url
and a handler
, as well as change some of the names of variables in our component.
import AsyncSelect from "./inputs/AsyncSelect"
const catHandler = (json) => {
return json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
}))
}
export default {
name: 'cat',
title: 'Cat',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string'
},
{
name: 'image',
title: 'Image',
type: 'image'
},
{
name: 'breed',
title: 'Breed',
type: 'string',
options: {
url: 'https://catfact.ninja/breeds', // Passes a the URL to fetch to our component
handler: catHandler // Passes a custom function for shaping the response into the correct format
},
inputComponent: AsyncSelect
}
]
}
import React, { useState, useEffect } from 'react'
import { Card, Stack, Select } from '@sanity/ui'
import { FormField } from '@sanity/base/components'
import PatchEvent, {set, unset} from '@sanity/form-builder/PatchEvent'
import { useId } from "@reach/auto-id"
const AsyncSelect = React.forwardRef((props, ref) => {
const [listItems, setListItems] = useState([])
const {
type, // Schema information
value, // Current field value
readOnly, // Boolean if field is not editable
markers, // Markers including validation rules
presence, // Presence information for collaborative avatars
compareValue, // Value to check for "edited" functionality
onFocus, // Method to handle focus state
onBlur, // Method to handle blur state
onChange, // Method to handle patch events,
} = props
//Destructure your URL and handler from options
const {
url,
handler,
} = type.options
// 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]
)
const inputId = useId()
useEffect(() => {
const getItems = async () => {
const items = await fetch(url)
.then(res => res.json())
.then(json => handler(json))
setListItems(items)
}
getItems()
}, [])
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
compareValue={compareValue} // Handles "edited" status
inputId={inputId} // Allows the label to connect to the input field
>
<Card padding={0}>
<Stack>
<Select
id={inputId} // A unique ID for this input
fontSize={2}
padding={[3, 3, 4]}
space={[3, 3, 4]}
value={value} // Current field value
readOnly={readOnly} // If "readOnly" is defined make this field read only
onFocus={onFocus} // Handles focus events
onBlur={onBlur} // Handles blur events
ref={ref}
onChange={handleChange} // A function to call when the input value changes
>
<option value={'---'}>---</option>
{listItems.map(({ value, title }) => (
<option
key={value}
value={value}
>
{title}
</option>
))}
</Select>
</Stack>
</Card>
</FormField>
)
})
export default AsyncSelect
To test that this will work with a different url, let's add a field to our cat document to select that particular cat's favorite dog breed.
import AsyncSelect from "./inputs/AsyncSelect"
const catHandler = (json) => {
return json.data.map(({ breed }) => ({
title: breed,
value: breed.toLowerCase().split(' ').join('-')
}))
}
const dogHandler = (json) => {
return Object.keys(json.message).map(breed => ({
title: breed.charAt(0).toUpperCase() + breed.slice(1),
value: breed
}))
}
export default {
name: 'cat',
title: 'Cat',
type: 'document',
fields: [
{
name: 'name',
title: 'Name',
type: 'string'
},
{
name: 'image',
title: 'Image',
type: 'image'
},
{
name: 'breed',
title: 'Breed',
type: 'string',
options: {
url: 'https://catfact.ninja/breeds',
handler: catHandler
},
inputComponent: AsyncSelect
},
{
name: 'favoriteDogBreed',
title: 'Favorite Dog Breed',
type: 'string',
options: {
url: 'https://dog.ceo/api/breeds/list/all',
handler: dogHandler
},
inputComponent: AsyncSelect
},
]
}
Success!
Go forth and select to your heart's asynchronous content!
Gotcha
It's important to keep in mind that this custom component performs a fetch every time it mounts. That means every time you navigate between documents the request will fire. If you're using a paid API, keep an eye on your usage so that you don't get an expensive surprise!
Sanity.io β build remarkable experiences at scale
Sanity.io is a platform to build websites and applications. It comes with great APIs that let you treat content like data. Give your team exactly what they need to edit and publish their content with the customizable Sanity Studio. Get real-time collaboration out of the box. Sanity.io comes with a hosted datastore for JSON documents, query languages like GROQ and GraphQL, CDNs, on-demand asset transformations, presentation agnostic rich text, plugins, and much more.
Don't compromise on developer experience. Join thousands of developers and trusted companies and power your content with Sanity.io. Free to get started, pay-as-you-go on all plans.