Pricing update: Free users
December 17, 2021

How to Asynchronously Populate List Options in the Sanity Studio

By Racheal Pennell

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.

Exploring the Out-of-the-Box List

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:

A cat document inside of the Studio with an empty select field

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!

Creating A Custom Input for Your List

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.

A simple input component that renders a div inside of our document

Now we can start to build out the functionality of our component.

Adding React Hooks and Sanity UI

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.

Adding Editor Affordances to your Component

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!

Inspecting our document shows that our changes are now being reflected in the data!

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

Adapting Your Component for Reusability

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!

We've successfully adapted our component for reusability!

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: Content Is Data

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.