Handling complex state in a Sanity custom component
Links
React State as a SnapshotRedux Core conceptReducer ConceptSanity custom componentAuthor
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.