Trouble with custom input for ecommerce app, resolved with conditional fields.

4 replies
Last updated: Dec 13, 2021
Hi, All. I'm working on setting up an ecommerce app for an artist, and I'm having trouble with a custom input. Each artwork will have two sets of options: size and media. I want the artist to be able to add the available size and media options, which will vary for each artwork, and then dynamically generate another set of inputs where the artist can add the price for each combination of options. For example, if the media options are "paper" and "canvas", and the size options are "large", "medium", and "small", I want to dynamically generate price inputs for "large paper", "large canvas", "medium paper", "medium canvas", etc (like what Shopify does with variants). It's easy enough to create a custom component that loops through the options to create all the combinations. I followed the Twitter text input example in the docs to create a
TextInput
for each combination, but this basically creates a single input that is repeated for each combination: adding a price to one input updates all the inputs. When I think through it this makes perfect sense, but I can't figure out how to create independent inputs. I looked at the instructions for working with objects using the
FormBuilderInput
and
FieldSet
, but couldn't figure it out - and I'm not sure that's what I want to be doing. Any help will be greatly appreciated. Thanks!
AI Update

I can help you with this! The issue you're experiencing is a common challenge when building variant-style custom inputs in Sanity. The problem is that each TextInput needs to update a specific path in your data structure, not just overwrite the entire value.

The solution is to structure your data as an array of objects where each object represents a variant (a size/media combination), and then use the correct Studio v3 patch operations to update individual items.

Data Structure

First, set up your schema to store variants as an array of objects:

{
  name: 'artwork',
  type: 'document',
  fields: [
    {
      name: 'sizes',
      type: 'array',
      of: [{type: 'string'}]
    },
    {
      name: 'media',
      type: 'array',
      of: [{type: 'string'}]
    },
    {
      name: 'variants',
      type: 'array',
      of: [{
        type: 'object',
        fields: [
          {name: 'size', type: 'string'},
          {name: 'media', type: 'string'},
          {name: 'price', type: 'number'}
        ]
      }],
      components: {
        input: VariantPriceMatrix // Your custom component
      }
    }
  ]
}

Custom Input Component (Studio v3)

Here's a working example that creates independent inputs for each variant. The critical difference from Studio v2 is that you call onChange() directly with patch operations—not PatchEvent.from():

import {set, setIfMissing, unset} from 'sanity'
import {Stack, TextInput, Text} from '@sanity/ui'
import {useCallback, useEffect} from 'react'

function VariantPriceMatrix(props) {
  const {value = [], onChange, schemaType} = props
  
  // Get sizes and media from the document
  const sizes = props.document?.sizes || []
  const mediaOptions = props.document?.media || []
  
  // Generate all combinations
  const combinations = sizes.flatMap(size => 
    mediaOptions.map(media => ({size, media}))
  )
  
  // Initialize variants array if needed
  useEffect(() => {
    if (combinations.length > 0 && value.length === 0) {
      const initialVariants = combinations.map(combo => ({
        _type: 'object',
        _key: `${combo.size}-${combo.media}`,
        size: combo.size,
        media: combo.media,
        price: undefined
      }))
      // Studio v3: call onChange directly with setIfMissing
      onChange(setIfMissing(initialVariants))
    }
  }, [combinations.length, value.length, onChange])
  
  // Update price for a specific variant
  const handlePriceChange = useCallback((variantKey, newPrice) => {
    const variantIndex = value.findIndex(v => v._key === variantKey)
    
    if (variantIndex === -1) {
      // If variant doesn't exist yet, add it
      const combo = combinations.find(c => `${c.size}-${c.media}` === variantKey)
      if (combo) {
        const newVariant = {
          _type: 'object',
          _key: variantKey,
          size: combo.size,
          media: combo.media,
          price: newPrice ? Number(newPrice) : undefined
        }
        onChange(setIfMissing([...value, newVariant]))
      }
      return
    }
    
    // Studio v3: call onChange directly with set() using array path
    if (newPrice === '') {
      onChange(unset([variantIndex, 'price']))
    } else {
      onChange(set(Number(newPrice), [variantIndex, 'price']))
    }
  }, [value, onChange, combinations])
  
  // Sync variants when combinations change
  useEffect(() => {
    if (combinations.length === 0) return
    
    const currentKeys = new Set(value.map(v => v._key))
    const neededKeys = new Set(combinations.map(c => `${c.size}-${c.media}`))
    
    // Add missing variants
    const toAdd = combinations
      .filter(c => !currentKeys.has(`${c.size}-${c.media}`))
      .map(combo => ({
        _type: 'object',
        _key: `${combo.size}-${combo.media}`,
        size: combo.size,
        media: combo.media,
        price: undefined
      }))
    
    // Remove variants that no longer match
    const toKeep = value.filter(v => neededKeys.has(v._key))
    
    if (toAdd.length > 0 || toKeep.length !== value.length) {
      onChange(set([...toKeep, ...toAdd]))
    }
  }, [combinations, value, onChange])
  
  if (combinations.length === 0) {
    return <Text muted>Add sizes and media options first</Text>
  }
  
  return (
    <Stack space={3}>
      {combinations.map(combo => {
        const variantKey = `${combo.size}-${combo.media}`
        const variant = value.find(v => v._key === variantKey)
        const currentPrice = variant?.price?.toString() || ''
        
        return (
          <Stack key={variantKey} space={2}>
            <Text size={1} weight="semibold">
              {combo.size} / {combo.media}
            </Text>
            <TextInput
              value={currentPrice}
              onChange={(event) => handlePriceChange(variantKey, event.currentTarget.value)}
              placeholder="Price"
              type="number"
            />
          </Stack>
        )
      })}
    </Stack>
  )
}

export default VariantPriceMatrix

Key Concepts for Studio v3

The critical part is understanding how custom input components work in Studio v3. Unlike Studio v2, you call onChange() directly with patch operations:

// ✅ Correct for Studio v3
onChange(set(newPrice, [variantIndex, 'price']))

// ❌ Wrong (Studio v2 pattern)
onChange(PatchEvent.from(set(newPrice, [variantIndex, 'price'])))

When you call set(newPrice, [variantIndex, 'price']), you're telling Sanity: "In the array at position variantIndex, update the price field to newPrice". This creates independent updates for each variant instead of replacing the entire value.

The _key field is essential for tracking items in the array. Using a combination of size and media (like "large-canvas") ensures each variant has a unique, stable identifier.

Alternative: Object-based Structure

If you prefer simpler path management, you could structure variants as an object with keys for each combination:

{
  name: 'variantPrices',
  type: 'object',
  components: {
    input: VariantPriceMatrix
  }
}

Then update individual prices like:

const handlePriceChange = (variantKey, newPrice) => {
  if (newPrice === '') {
    onChange(unset([variantKey]))
  } else {
    onChange(set(Number(newPrice), [variantKey]))
  }
}

This approach eliminates array index management since you're working with object keys directly. However, arrays give you more flexibility for querying and ordering variants in GROQ.

The custom input components documentation has more details on working with complex data structures, and the guide on understanding custom input components for complex objects shows additional patterns for handling nested data!

Hey User! Just to make sure I'm understanding correctly: do you want the custom input to generate schema for the artist to input values?
Hi, User. Yes, that's right. Here's the snippet of what I'm using to generate a custom input from the options:

return (
    <div>
        {props.parent.mediaOptions.map((media) => {
            return (
                props.parent.sizeOptions.map((size) => {
                    
                    return (
                        <FormField
                            description={type.description}
                            title={type.title}
                            compareValue={compareValue}
                            __unstable_marks={markers}
                            __unstable_presence={presence}
                            inputId={inputId}
                        >
                            <Flex align={'center'}>
                                <Text>{`${media} - ${size} - ${inputId}`}</Text>
                                <Box flex={1}>
                                    <TextInput
                                        id={inputId}
                                        value={value}
                                        readOnly={readOnly}
                                        placeholder={placeholder}
                                        onFocus={onFocus}
                                        onBlur={onBlur}
                                        onChange={handleChange}
                                    />
                                </Box>
                            </Flex>
                        </FormField>
                    )
                })
            )
        })}
    </div>
)
Got it. In this case you probably want to set this up using conditional fields . Take this example of a product:
export default {
  name: 'product',
  title: 'Product',
  type: 'document',
  fields: [
    {
      name: 'title',
      titlte: 'Title',
      type: 'string'
    },
    {
      name: 'mediaOptions',
      title: 'Media Options',
      type: 'array',
      of: [
        { type: 'string'}
      ],
      options: {
        list: [
          { title: 'Paper', value: 'paper'},
          { title: 'Canvas', value: 'canvas'},
        ]
      }
    },
    {
      name: 'paperInfo',
      title: 'Paper Info',
      type: 'array',
      hidden: ({document}) => !document.mediaOptions?.includes('paper'),
      of: [
        {
          name: 'option',
          title: 'Option',
          type: 'object',
          fields: [
            {
              name: 'size',
              title: 'Size',
              type: 'string',
              options: {
                list: [
                  { title: 'Small', value: 'small' },
                  { title: 'Medium', value: 'medium' },
                  { title: 'Large', value: 'large' },
                ]
              }
            },
            {
              name: 'price',
              title: 'Price',
              type: 'number'
            }
          ]
        }
      ]
    },
    {
      name: 'canvasInfo',
      title: 'Canvas Info',
      type: 'array',
      hidden: ({document}) => !document.mediaOptions?.includes('canvas'),
      of: [
        {
          name: 'option',
          title: 'Option',
          type: 'object',
          fields: [
            {
              name: 'size',
              title: 'Size',
              type: 'string',
              options: {
                list: [
                  { title: 'Small', value: 'small' },
                  { title: 'Medium', value: 'medium' },
                  { title: 'Large', value: 'large' },
                ]
              }
            },
            {
              name: 'price',
              title: 'Price',
              type: 'number'
            }
          ]
        }
      ]
    }
  ]
}
I just spun this up quickly, so it's maybe not the best content model, but you can see that you can change the input fields available based off of an editor's selection in the document. You'd probably want to pull the
option
object out into it's own file, but I just copy/pasted for the sake of the example here.
Thanks, User. I'll give this a spin.

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?