Nuagedelait.
Engineer ManagerCreative React DevelopperNextjs & Sanity Lover
< all articles

Handling complex state in a Sanity custom component

React state management

First, to follow this guide, you should have knowledge of React and Redux core concept (or reducer concept), and especially how the state is updated

Now, try to imagine this with an asynchronous function in the middle with no idea when it will be resolved... (props.onChange) :

- Change the sub-value of a field, then trigger the onChange
- Change another sub-value of the same field before saving is done

Let's find a elegant way to solve this issue, and create a model we can use in many of our custom components

There is three main goals here :
- Managing complex state without redux or zustand library
- Use a state instead of the value received by Sanity core
- Get control over when the modifications are saved

PS : you can do it with useState (one or severals), but it is more difficult, less performant (because of the useCallback that will depend on variables) and very hard to maintain.

You can of course split the code into several modules.


The component

The component is a simple time slot with start time and end time.
The state associated is not a very complex one but is enough to understand the logic.

Let's begin with the component and the template

You will need :
- Static data for hours and minutes
- A react component rendering 4 selects
- The value and onChange received in the props

// /components/slot.jsx

import {Card, Stack, Select, Text, Flex} from '@sanity/ui'

//Define hours and minutes data sources
const hours = Array.apply(null, Array(24)).map(function (x, i) { return i;})
const minutes = [0, 15, 30, 45]

export default (props) => {
  
  // Data from Sanity
  const {onChange, value, ...rest} = props;
 
  return (
  <Flex>
      <Stack space={3}>
          <Text size={1}>Start</Text>
          <Flex>
              <Select
                  value={0} //<- Future spot for dynamic data
                  data-value="start-hours" //<- we want one function to rules them all, so we differenciate the select by a dataset attribute
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}> //< don't forget the key
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={0}
                  data-value="start-minutes"
              >
                  {minutes.map((minutes, index) => (
                      <option key={index} value={minutes}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
      <Stack space={3} marginLeft={4}>
          <Text size={1}>'End'</Text>
          <Flex>
              <Select
                  value={0}
                  data-value="end-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={0}
                  data-value="end-minutes"
              >
                  {minutes.map((minute, index) => (
                      <option key={index} value={minute}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
  </Flex>
  )
}


Now we can handle the change with the same function for all four selects :

// /components/slot.jsx

import {useCallback} from 'react'
import {Card, Stack, Select, Text, Flex} from '@sanity/ui'

const hours = Array.apply(null, Array(24)).map(function (x, i) { return i;})
const minutes = [0, 15, 30, 45]

export default (props) => {
  
  const {onChange, value, ...rest} = props;
  
  // When a function is defined within the component, there's always a usecallback
  const handleChange = useCallback( () => {
    // magic here
  },[])
  
  return (
  <Flex>
      <Stack space={3}>
          <Text size={1}>Start</Text>
          <Flex>
              <Select
                  value={0}
                  onChange={handleChange}
                  data-value="start-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={0}
                  onChange={handleChange}
                  data-value="start-minutes"
              >
                  {minutes.map((minutes, index) => (
                      <option key={index} value={minutes}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
      <Stack space={3} marginLeft={4}>
          <Text size={1}>'End'</Text>
          <Flex>
              <Select
                  value={0}
                  onChange={handleChange}
                  data-value="end-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={0}
                  onChange={handleChange}
                  data-value="end-minutes"
              >
                  {minutes.map((minute, index) => (
                      <option key={index} value={minute}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
  </Flex>
  )
}

The data is composed of two fields with two sub fields each that define the schema and state

// Schema definition : slot.js

import { defineType } from 'sanity'
import Slot from 'slot.jsx'

export default defineType({
    name: 'slots',
    title: 'Slots',
    type: 'object',
    fields: [
     {
         name: 'start',
         title: 'Start',
         type: 'times'
      },
      {
        name: 'end',
        title: 'End',
        type: 'times'
      }
    ],
    components: {
        input: Slot
    }
})

// Schema definition : times.js

import { defineType } from 'sanity';

export default defineType({
    name: 'times',
    title: 'Times',
    type: 'object',
    fields: [
        {
            name: 'hours',
            title: 'Hours',
            type: 'number'
        },
        {
            name: 'minutes',
            title: 'Minutes',
            type: 'number'
        },
    ],
})
// /components/slot.jsx

import {useCallback, useReducer} from 'react'
import {Card, Stack, Select, Text, Flex} from '@sanity/ui'

const hours = Array.apply(null, Array(24)).map(function (x, i) { return i;})
const minutes = [0, 15, 30, 45]

export default (props) => {
  
  const {onChange, value, ...rest} = props;
  
  // When a function is defined within the component, there's always a usecallback
  const reducer = useCallback((state, action) => {
    return state; //<- For now, we juste return the same value
  },[])
  
  //Define the state structure
  const  initialState = {
    start: value.start || {
      hours: 0,
      minutes: 0,
    },
    end: value.end || {
      hours: 0,
      minutes: 0,
    }
  }
 
  const [state, dispatch] = useReducer(reducer, initialState)
  
  const handleChange = useCallback( () => {
    // dispatch comming here
  },[])
  
  return (
  <Flex>
      <Stack space={3}>
          <Text size={1}>Start</Text>
          <Flex>
              <Select
                  value={state.start.hours} //<- reactive data
                  onChange={handleChange}
                  data-value="start-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={state.start.minutes}
                  onChange={handleChange}
                  data-value="start-minutes"
              >
                  {minutes.map((minutes, index) => (
                      <option key={index} value={minutes}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
      <Stack space={3} marginLeft={4}>
          <Text size={1}>'End'</Text>
          <Flex>
              <Select
                  value={state.end.hours}
                  onChange={handleChange}
                  data-value="end-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={state.end.minutes}
                  onChange={handleChange}
                  data-value="end-minutes"
              >
                  {minutes.map((minute, index) => (
                      <option key={index} value={minute}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
  </Flex>
  )
}


Next, we need to define the actions ! If you ever worked with Redux, it is very much the same structure.

// /components/slot.jsx

import {useCallback, useReducer} from 'react'
import {Card, Stack, Select, Text, Flex} from '@sanity/ui'

const hours = Array.apply(null, Array(24)).map(function (x, i) { return i;})
const minutes = [0, 15, 30, 45];

// Always use variable instead of string to avoid typo bugs
const actions = {
    CHANGED_START_MINUTES: 'CHANGED-START-MINUTES',
    CHANGED_START_HOURS: 'CHANGED-START-HOURS',
    CHANGED_END_MINUTES: 'CHANGED-END-MINUTES',
    CHANGED_END_HOURS: 'CHANGED-END-HOURS'
}

export default (props) => {
  
  const {onChange, value, ...rest} = props;
  
  const reducer = useCallback((state, action) => {
    
    // Depending on the action type, update the state
    switch (action.type) {
      case actions.CHANGED_START_HOURS:
          state.start.hours = action.payload
          break
      case actions.CHANGED_START_MINUTES:
          state.start.minutes = action.payload
          break
      case actions.CHANGED_END_HOURS:
          state.end.hours = action.payload
          break
      case actions.CHANGED_END_MINUTES:
          state.end.minutes = action.payload
          break
    }
    
    // And then return it
    return state; 
  },[])
  
  const  initialState = {
    start: value.start || {
      hours: 0,
      minutes: 0,
    },
    end: value.end || {
      hours: 0,
      minutes: 0,
    }
  }
 
  const [state, dispatch] = useReducer(reducer, initialState)
  
  const handleChange = useCallback( (event) => {
    const target = event.target.dataset.value; //<- get the select name
    
    // Define the type depending on the select name
    let type = null;
    
    if (target.indexOf('start') !== -1) {
      // start select
      if (target.indexOf('minutes') !== -1) {
          type = actions.CHANGED_START_MINUTES
      } else {
          type = actions.CHANGED_START_HOURS
      }
    } else {
      // end select
      if (target.indexOf('minutes') !== -1) {
          type = actions.CHANGED_END_MINUTES
      } else {
          type = actions.CHANGED_END_HOURS
      }
    }
    
    // Dispatch the type and the value of the select
    dispatch({
        type,
        payload: parseInt(event.target.value),
    })
    
  },[])
  
  return (
  <Flex>
      <Stack space={3}>
          <Text size={1}>Start</Text>
          <Flex>
              <Select
                  value={state.start.hours}
                  onChange={handleChange}
                  data-value="start-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={state.start.minutes}
                  onChange={handleChange}
                  data-value="start-minutes"
              >
                  {minutes.map((minutes, index) => (
                      <option key={index} value={minutes}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
      <Stack space={3} marginLeft={4}>
          <Text size={1}>'End'</Text>
          <Flex>
              <Select
                  value={state.end.hours}
                  onChange={handleChange}
                  data-value="end-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={state.end.minutes}
                  onChange={handleChange}
                  data-value="end-minutes"
              >
                  {minutes.map((minute, index) => (
                      <option key={index} value={minute}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
  </Flex>
  )
}

And now the best part :

// /components/slot.jsx

import {useCallback, useReducer} from 'react'
import {Card, Stack, Select, Text, Flex} from '@sanity/ui'
import {set} from 'sanity'

const hours = Array.apply(null, Array(24)).map(function (x, i) { return i;})
const minutes = [0, 15, 30, 45];

const actions = {
    CHANGED_START_MINUTES: 'CHANGED-START-MINUTES',
    CHANGED_START_HOURS: 'CHANGED-START-HOURS',
    CHANGED_END_MINUTES: 'CHANGED-END-MINUTES',
    CHANGED_END_HOURS: 'CHANGED-END-HOURS'
}

export default (props) => {
  
  const {onChange, value, ...rest} = props;
  
  const reducer = useCallback((state, action) => {
    
    switch (action.type) {
      case actions.CHANGED_START_HOURS:
          state.start.hours = action.payload
          break
      case actions.CHANGED_START_MINUTES:
          state.start.minutes = action.payload
          break
      case actions.CHANGED_END_HOURS:
          state.end.hours = action.payload
          break
      case actions.CHANGED_END_MINUTES:
          state.end.minutes = action.payload
          break
    }
    
    // The reason of declaring the reducer within the component after spreading props :
    
    onChange(set(state));
    
    return state; 
  },[])
  
  const  initialState = {
    start: value.start || {
      hours: 0,
      minutes: 0,
    },
    end: value.end || {
      hours: 0,
      minutes: 0,
    }
  }
 
  const [state, dispatch] = useReducer(reducer, initialState)
  
  const handleChange = useCallback( (event) => {
    const target = event.target.dataset.value;
    let type = null;
    
    if (target.indexOf('start') !== -1) {
      // start select
      if (target.indexOf('minutes') !== -1) {
          type = actions.CHANGED_START_MINUTES
      } else {
          type = actions.CHANGED_START_HOURS
      }
    } else {
      // end select
      if (target.indexOf('minutes') !== -1) {
          type = actions.CHANGED_END_MINUTES
      } else {
          type = actions.CHANGED_END_HOURS
      }
    }
    
    dispatch({
        type,
        payload: parseInt(event.target.value),
    })
    
  },[])
  
  return (
  <Flex>
      <Stack space={3}>
          <Text size={1}>Start</Text>
          <Flex>
              <Select
                  value={state.start.hours}
                  onChange={handleChange}
                  data-value="start-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={state.start.minutes}
                  onChange={handleChange}
                  data-value="start-minutes"
              >
                  {minutes.map((minutes, index) => (
                      <option key={index} value={minutes}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
      <Stack space={3} marginLeft={4}>
          <Text size={1}>'End'</Text>
          <Flex>
              <Select
                  value={state.end.hours}
                  onChange={handleChange}
                  data-value="end-hours"
              >
                  {hours.map((hour, index) => (
                      <option key={index} value={hour}>
                          {hour}
                      </option>
                  ))}
              </Select>
              <Select
                  value={state.end.minutes}
                  onChange={handleChange}
                  data-value="end-minutes"
              >
                  {minutes.map((minute, index) => (
                      <option key={index} value={minute}>
                          {minute}
                      </option>
                  ))}
              </Select>
          </Flex>
      </Stack>
  </Flex>
  )
}

And voila. You can enjoy that :

- The state is simple to read, maintain and follow your Sanity Schema (you can put your entire schema in it)
- Your JSX depend on the state and not the value (update is instant)
- You have only one place in your code to call the Sanity save function with complete control over the passed data

and a bonus :
- The update functions use React useCallback optimization and are declared only once (vs need updates if you choose useState)

If you want to go further, you can optimize your component :
- Use a derived state in the reducer (because normaly state is read only)
- Pass a function as the intialization of useReducer to avoid recalculations.