How to build a custom input component that dynamically displays a list of checkboxes based on the current value of a separate field.

10 replies
Last updated: Apr 4, 2024
Hi everyone! Is it possible to build a custom input component that dynamically displays a list of checkboxes w/ labels based on the current value of separate field?
For example, say it was a shopping list, and the goal would be for the contents of the list to update based on which meal was selected. I have the document schemas set up for this example (will post in thread below), but am struggling to get the component to 1) update the list when the new meal is selected, and 2) patch the current list to content lake.
AI Update

Yes, absolutely! This is a great use case for a custom input component with useFormValue. Here's how to build it:

Basic Structure

Your custom input component needs to:

  1. Use useFormValue to watch the meal field
  2. React to changes with useEffect
  3. Use onChange with set() and unset() to patch the Content Lake

Here's a complete example for your shopping list scenario:

import { useFormValue, set, unset } from 'sanity'
import { useEffect, useState, useCallback } from 'react'
import { Stack, Checkbox, Card } from '@sanity/ui'

export const ShoppingListInput = (props) => {
  const { onChange, value = [] } = props
  const mealRef = useFormValue(['meal']) // Watch the meal field
  const [ingredients, setIngredients] = useState([])

  // Fetch ingredients when meal changes
  useEffect(() => {
    if (mealRef?._ref) {
      // Query Sanity for the meal's ingredients
      client.fetch(
        `*[_id == $mealId][0].ingredients`,
        { mealId: mealRef._ref }
      ).then(setIngredients)
    } else {
      setIngredients([])
    }
  }, [mealRef?._ref]) // Re-run when meal reference changes

  const handleCheckboxChange = useCallback((ingredient, checked) => {
    let newValue
    if (checked) {
      // Add ingredient to array
      newValue = [...value, ingredient]
    } else {
      // Remove ingredient from array
      newValue = value.filter(item => item._key !== ingredient._key)
    }
    
    // Patch to Content Lake - correct v3 pattern
    onChange(newValue.length > 0 ? set(newValue) : unset())
  }, [onChange, value])

  return (
    <Stack space={3}>
      {ingredients.map((ingredient) => (
        <Card key={ingredient._key} padding={2}>
          <Checkbox
            checked={value.some(v => v._key === ingredient._key)}
            onChange={(e) => handleCheckboxChange(ingredient, e.target.checked)}
          >
            {ingredient.name}
          </Checkbox>
        </Card>
      ))}
    </Stack>
  )
}

Key Points

1. The useFormValue hook watches other fields in your document. Pass it an array path like ['meal'] for a top-level field, or ['parent', 'child'] for nested fields.

2. The dependency array in useEffect should include mealRef?._ref (not just mealRef) to trigger updates when the actual reference changes, not just the object identity.

3. Patching with onChange (Studio v3): As shown in the official guide, you pass the result of set() or unset() directly to onChange():

onChange(nextValue ? set(nextValue) : unset())

Important: In Studio v3, you do NOT use PatchEvent.from() - that was the v2 pattern. The v3 API is simpler and cleaner.

4. Performance optimization: Wrap your change handler in useCallback with onChange and value in the dependency array to prevent unnecessary re-renders.

Schema Setup

In your schema, attach the component like this:

{
  name: 'shoppingList',
  type: 'array',
  of: [{ type: 'reference', to: [{ type: 'ingredient' }] }],
  components: {
    input: ShoppingListInput
  }
}

Common Gotchas

  • Context requirement: useFormValue only works inside custom input components, not in document actions or other contexts
  • Real-time updates: The hook automatically re-renders when the watched field changes
  • Array handling: Make sure each ingredient has a _key for proper React rendering and array patching
  • Import from 'sanity': Both set and unset are imported from the 'sanity' package in v3

This pattern works great for dependent fields, conditional inputs, and dynamic lists based on other selections!

Here are the document schemas I'm working with:Meal:

// Meal.js
export default {
  title: 'Meal',
  name: 'meal',
  type: 'document',
  fields: [
    {
      title: 'Name',
      name: 'name',
      type: 'string',
    },
    {
      title: 'Ingredients',
      name: 'ingredients',
      type: 'array',
      description: 'ingredients for the meal',
      of: [{type: 'ingredient'}],
    },
  ],
}
Ingredient:

// Ingredient.js
export default {
  title: 'Ingredient',
  name: 'ingredient',
  type: 'document',
  fields: [
    {
      title: 'Ingredient Name',
      name: 'ingredientName',
      type: 'string',
    },
  ],
}
Shopper:

// Shopper.js
import {MealIngredientsComponent} from './MealIngredientsComponent'

export default {
  title: 'Shopper',
  name: 'shopper',
  type: 'document',
  fields: [
    {
      title: 'Name',
      name: 'name',
      type: 'string',
    },
    {
      title: 'Meal',
      name: 'meal',
      type: 'reference',
      description: 'Select the meal for this shopper',
      to: [{type: 'meal'}],
    },
    {
      title: 'Shopping List',
      name: 'shoppingList',
      type: 'array',
      description: 'List of ingredients to buy for selected meal',
      of: [{type: 'shoppingListItem'}],
      components: {
        input: MealIngredientsComponent,
      },
    },
  ],
}
Shopping List Item:

// ShoppingListItem.js
export default {
  title: 'Shopping List Item',
  name: 'shoppingListItem',
  type: 'document',
  fields: [
    {
      title: 'Ingredient',
      name: 'ingredient',
      type: 'string',
    },
    {
      title: 'Purchased Item',
      name: 'purchased',
      type: 'boolean',
    },
  ],
}

And here's hacky custom component I've pieced together so far:

import {useFormValue, useDocumentStore, set, unset} from 'sanity'
import {useEffect, useCallback, useState} from 'react'
import {Checkbox} from '@sanity/ui'

export const MealIngredientsComponent = (props) => {
  const {onChange, value} = props

  // define component state
  const [shoppingList, setShoppingList] = useState([])
  const [meal, setMeal] = useState(null) // this will be the meal object that is selected in the shopper document

  // Set up a GROC query to get the ingredient list for the currently selected meal
  const docId = useFormValue(['_id']) // get the id of the currently active shopper
  console.log(docId)
  const query = '*[_id == $currentDoc]{meal->}'
  const documentStore = useDocumentStore()
  let queryResults = documentStore.listenQuery(query, {currentDoc: docId}, {})
  useEffect(() => {
    const subscription = queryResults.subscribe({
      next(result) {
        if (result[0].meal === undefined) return
        if (!result[0].meal) {
          setShoppingList([]) // update component state
          unset()
        } else {
          if (result[0].meal.name === meal) {
            return
          } else {
            setMeal(result[0].meal.name)
          }

          const requiredIngredients = result[0].meal.ingredients
          const newShoppingList = requiredIngredients.map((item) => ({
            ingredient: item.ingredientName,
            purchased: false,
          }))
          setShoppingList(newShoppingList)
        }
      },
    })
    return () => {
      subscription.unsubscribe()
    }
  })

  // define the event handler for the checkbox
  const handleChange = useCallback(
    (e, ingredient) => {
      const {checked} = e.target
      setShoppingList((shoppingList) => {
        return shoppingList.map((item) => {
          if (item.ingredient === ingredient) {
            return {
              ingredient: item.ingredient,
              purchased: checked,
            }
          }
          return item
        })
      })
      set(shoppingList)
    },
    [onChange],
  )

  let shoppingListNodes = shoppingList.map((r) => {
    return (
      <div key={r.ingredient}>
        <Checkbox checked={r.purchased} onChange={(e) => handleChange(e, r.ingredient)} />
        <label>{r.ingredient}</label>
      </div>
    )
  })

  return <div>{shoppingListNodes}</div>
}

Any tips/guidance would be greatly appreciated!
I did something similar in the past, though it was using references so your data would look different:
const handleClick = useCallback(
      (e) => {
        const inputValue = {
          _type: 'reference',
          _ref: e.target.value,
        }

        if (value) {
          if (value.some((country) => country._ref === inputValue._ref)) {
            onChange(set(value.filter((item) => item._ref != inputValue._ref)))
          } else {
            onChange(set([...value, inputValue]))
          }
        } else {
          onChange(set([inputValue]))
        }
      },
      [value],
    )
Ah, I see from your example I wasn't wrapping my
set(...)
call in the
onChange
function. I think I'm missing something fundamental about how these input form properties work (or really, input components in general 🙃). Are you updating the internal component state as well as the data in content lake? Would you mind sharing the code for your full component?
So, it’s a V2 component, so a lot of the syntax will be incorrect. I just updated a bit of it up there ☝️. The essential functionality is the same, though:
import React, { useEffect, useState } from 'react';
import { Card, Flex, Checkbox, Box, Text } from '@sanity/ui';
import { FormField } from '@sanity/base/components';
import PatchEvent, { set, unset } from '@sanity/form-builder/PatchEvent';
import { useId } from '@reach/auto-id';
import client from 'part:@sanity/base/client';

const studioClient = client.withConfig({ apiVersion: '2021-10-21' });

const ReferenceSelect = React.forwardRef((props, ref) => {
  const [countries, setCountries] = useState([]);

  useEffect(() => {
    const fetchCountries = async () => {
      await studioClient
        .fetch(
          `*[_type == 'country']{
          _id,
          title
        }`
        )
        .then(setCountries);
    };

    fetchCountries();
  }, []);

  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;

  const handleClick = React.useCallback(
    (e) => {
      const inputValue = {
        _key: e.target.value.slice(0, 10),
        _type: 'reference',
        _ref: e.target.value,
      };

      if (value) {
        if (value.some((country) => country._ref === inputValue._ref)) {
          onChange(
            PatchEvent.from(
              set(value.filter((item) => item._ref != inputValue._ref))
            )
          );
        } else {
          onChange(PatchEvent.from(set([...value, inputValue])));
        }
      } else {
        onChange(PatchEvent.from(set([inputValue])));
      }
    },
    [value]
  );

  const inputId = useId();

  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
      readOnly={readOnly}
    >
      {countries.map(country => (
        <Card padding={2}>
          <Flex align='center'>
            <Checkbox
              id={country._id}
              style={{ display: 'block' }}
              onClick={handleClick}
              value={country._id}
              checked={
                value ? value.some((item) => item._ref === country._id) : false
              }
            />
            <Box flex={1} paddingLeft={3}>
              <Text>
                <label for={country._id}>{country.title}</label>
              </Text>
            </Box>
          </Flex>
        </Card>
      ))}
    </FormField>
  );
});

export default ReferenceSelect;
Thank you, much appreciated!! 🙏 I'll comb through this to see if I can find any clues about why mine isn't working as expected
If I get some time this afternoon I’ll reproduce your component and see what I can find!
Thank you, I'm in over my head here! I'll post some more specific issues I'm having once I have a better handle on what's going on
They’re definitely tough to grasp when you first start working with them. It’ll come together though!
I think I got it working! Your example was instrumental in helping me get there, so thank you again 😄 Here's where I landed, in case this issue is helpful for anyone else:
import {useClient, useFormValue, set} from 'sanity'
import {useEffect, useCallback, useState} from 'react'
import {Flex, Checkbox, Box, Text} from '@sanity/ui'

export const MealIngredientsComponent = (props) => {
  const {onChange, value} = props
  const [shoppingList, setShoppingList] = useState(value)
  const sanityClient = useClient({apiVersion: '2023-01-01'})
  const meal = useFormValue(['meal'])

  // --- Fetch ingredients for the current meal
  useEffect(() => {
    if (!meal) {
      updateShoppingList([])
    } else {
      const fetchMealIngredients = async () => {
        await sanityClient
          .fetch(`*[_type == "meal" && _id == "${meal._ref}"]{ingredients}`)
          .then((resp) => {
            updateShoppingList(resp[0].ingredients)
          })
      }
      fetchMealIngredients()
    }
  }, [meal])

  // --- Update shopping list based on new ingredients from current meal
  const updateShoppingList = (newIngredients) => {
    // remove any existing ingredients that are NOT in the list of new ingredients
    let newList = shoppingList.filter((item) => {
      return newIngredients.some((newItem) => newItem.ingredientName === item.ingredient)
    })

    // add new ingredients that are NOT in the existing list of ingredients
    newIngredients.forEach((newItem) => {
      if (!newList.some((item) => item.ingredient === newItem.ingredientName)) {
        newList.push({
          _key: newItem._key,
          ingredient: newItem.ingredientName,
          purchased: false,
        })
      }
    })

    // update component state and content lake
    setShoppingList(newList)
    onChange(set(newList))
  }

  // -- Event handler for each checkbox
  const handleChange = useCallback(
    (e, item) => {
      const {checked} = e.target
      const ingredientKey = item._key
      const updatedList = shoppingList.map((listItem) => {
        if (listItem._key === ingredientKey) {
          return {
            ...listItem,
            purchased: checked,
          }
        } else {
          return listItem
        }
      })
      setShoppingList(updatedList)
      onChange(set(updatedList))
    },
    [shoppingList],
  )

  return (
    <div>
      {shoppingList.map((item) => (
        <div key={item._key}>
          <Flex align="center" padding={1}>
            <Checkbox
              id={item._key}
              checked={item.purchased}
              onChange={(e) => handleChange(e, item)}
            />
            <Box flex={1} paddingLeft={3}>
              <Text>{item.ingredient}</Text>
            </Box>
          </Flex>
        </div>
      ))}
    </div>
  )
}
Thank you for sharing!

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?