Managing states in ReactJS could be tricky. Changing state could trigger components or pages re-render. If it's not managed well, components re-render could lead to performance issues.
It is not uncommon to see a component that has dozens state. Some states are changed whenever other state changed. Also those state could make other states changed and so on. Fortunately, ReactJS has several utilities that could cover something like this. I will explain in a simple real use case how I approach this situation.
Use Case
Let's suppose there's form with 2 inputs as shown in the image below,
By they way, I'm gonna implement the view by using React Typescript and Material UI. Typescript would be our best friend when building large scale application because of its type safety. Material UI would give us nice-looking theme while also giving us a lot of flexibility.
The first input is weight type that consists of two different options, Kg and Pound. Second input is number input for weight value. The second input has additional property that shows value unit in the end of the input. I'm gonna name this property adornment since Material UI also give the same name for this type of property.
The adornment value depends on first input. If user choose Kg for the value, weight value input adornment will show kg. Also for Pound value the adornment will show lb.
Our base view will be implemented like this,
import React from 'react'
import {
Button,
FormControl,
FormControlLabel,
FormLabel,
InputAdornment,
Radio,
RadioGroup,
TextField,
Typography
} from '@mui/material'
function OrderForm() {
return (
<>
<Typography variant="h2" fontWeight={600} fontSize={24} sx={{ mb: 2 }}>
Order Form
</Typography>
<form
style={{ width: '80%', maxWidth: '1000px', display: 'block', margin: 'auto' }}
>
<FormControl>
<FormLabel id="payloadType-input" sx={{ textAlign: 'start' }}>
Payload Type
</FormLabel>
<RadioGroup
row
aria-labelledby="payloadType-input"
name="payloadType"
>
<FormControlLabel value="kg" control={<Radio />} label="Kg" />
<FormControlLabel value="pound" control={<Radio />} label="Pound" />
</RadioGroup>
<FormLabel id="payloadWeight-input" sx={{ textAlign: 'start', mt: 1 }}>
Weight
</FormLabel>
<TextField
aria-labelledby="payloadWeight-input"
name="payloadWeight"
size='small'
type="number"
fullWidth
InputProps={{
endAdornment: <InputAdornment sx={{ width: 12 }} position="end">kg</InputAdornment>,
}}
/>
<Button type="submit" variant="contained" sx={{ mt: 3 }}>Submit</Button>
</FormControl>
</form>
</>
)
}
export default OrderForm
Next let's implement the states.
Naive Approach
If we look into the problem at a glance, there are three states that we gonna maintain. The first and second state will be the input values. The third state will be the input adornment. It makes sense making it as a state since we need to re-render the screen whenever the adornment change. Our code will be something like this.
// ...
function OrderForm() {
// States
const [payloadType, setPayloadType] = React.useState<string>('kg')
const [payloadWeight, setPayloadWeight] = React.useState<string>('0')
const [adornmentLabel, setAdornmentLabel] = React.useState<string>('kg')
return (
// ...
)
}
export default OrderForm
Our inputs will be controlled by payloadType
and weight
state. I will be using conventional input state binding for this example.
// ...
function OrderForm() {
// States
const [payloadType, setPayloadType] = React.useState<string>('kg')
const [payloadWeight, setPayloadWeight] = React.useState<string>('0')
const [adornmentLabel, setAdornmentLabel] = React.useState<string>('kg')
React.useEffect(() => {
// Assign new adornment
const newAdornment = payloadType === 'pound' ? 'lb' : 'kg'
setAdornmentLabel(newAdornment)
}, [payloadType, setAdornmentLabel])
return (
<>
<Typography variant="h2" fontWeight={600} fontSize={24} sx={{ mb: 2 }}>
Order Form
</Typography>
<form
style={{ width: '80%', maxWidth: '1000px', display: 'block', margin: 'auto' }}
>
<FormControl>
<FormLabel id="payloadType-input" sx={{ textAlign: 'start' }}>
Payload Type
</FormLabel>
<RadioGroup
row
aria-labelledby="payloadType-input"
name="payloadType"
// Input state binding
onChange={(e) => setPayloadType(e.target.value)}
value={payloadType}
>
<FormControlLabel value="kg" control={<Radio />} label="Kg" />
<FormControlLabel value="pound" control={<Radio />} label="Pound" />
</RadioGroup>
<FormLabel id="payloadWeight-input" sx={{ textAlign: 'start', mt: 1 }}>
Weight
</FormLabel>
<TextField
aria-labelledby="payloadWeight-input"
name="payloadWeight"
size='small'
type="number"
fullWidth
// Input state binding
value={payloadWeight}
onChange={(e) => setPayloadWeight(e.target.value)}
// Replace with state
InputProps={{
endAdornment: <InputAdornment sx={{ width: 12 }} position="end">{adornmentLabel}</InputAdornment>,
}}
/>
<Button type="submit" variant="contained" sx={{ mt: 3 }}>Submit</Button>
</FormControl>
</form>
</>
)
}
// ...
Whenever our payloadType
value changes, our input adornment should be changed based on payloadType
value. In ReactJS, in order to do something whenever some values change, typically we can use useEffect
. The useEffect
will receive payloadType
state in its dependency array since we want to do something with input adornment whenever payloadType
change.
Let's try our implementation.
Yay, it works! But, does it?
The Problem
Surely it works perfectly. But let me show you something.
I'm gonna put a counter that's increased whenever the screen re-rendered. I will also log the counter in the console.
// ...
let counter = 0
function OrderForm() {
// States
const [payloadType, setPayloadType] = React.useState<string>('kg')
const [payloadWeight, setPayloadWeight] = React.useState<string>('0')
const [adornmentLabel, setAdornmentLabel] = React.useState<string>('kg')
// Print and increase the value
console.log('Screen is refreshed ', ++counter, ' times')
// ...
I will refresh the page once and change the payloadType
value. Take a look at the logging result.
Initially, the component is mounted for the first time so the console shows 1 value. After that, I modify payloadType
value. The console is showing 2 and followed by 3. Logically, the counter should only be increased 1 because I only change payloadType
value once. What's happened?
Ah I see, the value is increased two times because there are two states change. The payload type input and then the input adornment.
Sure, that's the answer. But is it the best way we can do?
Doing sort kind of thing could lead into problem. For now it's fine because there are only two inputs and three states we maintain. But as our application grow, keeping this habit could lead into performance issue.
I did this once in my previous work. First there were only like five inputs and several states. Some of those states were related to input adornment and other supporting states that were updated whenever other states update.
As time went on, the requirements grew, and I still kept doing this habit. There were 15 inputs in total with their corresponding states and other states. There was notable input lag whenever user typed into field. Interacting with the form became very painful because of this.
There are some solutions for the problem, especially dealing with large form. But I will keep this topic for later. Right now I'm gonna focus on how do we chaining those states update so that we could minimize component re-render. So how do we tackle this problem?
Update States Synchronously
Forget about useEffect
, forget about chaining states. Sometimes simplest way is the best way. Instead of update the adornment inside useEffect
, why don't we update the adornment directly after updating the payloadType
? In order to do this we have to move our payloadType
onChange
handler into separate function.
// ...
const payloadTypeChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setPayloadType(e.target.value)
}
return (
<>
<Typography variant="h2" fontWeight={600} fontSize={24} sx={{ mb: 2 }}>
Order Form
</Typography>
<form onSubmit={submitHandler} style={{ width: '80%', maxWidth: '1000px', display: 'block', margin: 'auto' }}>
<FormControl>
<FormLabel id="payloadType-input" sx={{ textAlign: 'start' }}>
Payload Type
</FormLabel>
<RadioGroup
row
aria-labelledby="payloadType-input"
name="payloadType"
// Move this
onChange={payloadTypeChangeHandler}
value={payloadType}
>
// ...
After that we can also update input adornment inside the same function.
// ...
const payloadTypeChangeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
setPayloadType(e.target.value)
const newAdornment = payloadType === 'pound' ? 'lb' : 'kg'
setAdornmentLabel(newAdornment)
}
// ...
Finally we can remove our useEffect
. Now our page would only by rendered once whenever our payloadType
change.
Using useMemo
I personally don't like the first approach. I think onChange
function handler should only handle / control input change so that our handler won't be bloated. Whatever change that involve payloadType
should stay away from our change handler.
We can achieve this by using useMemo
. useMemo
is a hook that save value as a result from complex calculation. Take a look at this example.
import React from 'react'
import {
Button,
FormControl,
FormControlLabel,
FormLabel,
InputAdornment,
Radio,
RadioGroup,
TextField,
Typography
} from '@mui/material'
function OrderForm() {
const [payloadType, setPayloadType] = React.useState<string>('kg')
const [payloadWeight, setPayloadWeight] = React.useState<string>('0')
// Take a look
const adornmentLabel = payloadType === 'pound' ? 'lb' : 'kg'
return (
<>
<Typography variant="h2" fontWeight={600} fontSize={24} sx={{ mb: 2 }}>
Order Form
</Typography>
<form
style={{ width: '80%', maxWidth: '1000px', display: 'block', margin: 'auto' }}
>
<FormControl>
<FormLabel id="payloadType-input" sx={{ textAlign: 'start' }}>
Payload Type
</FormLabel>
<RadioGroup
row
aria-labelledby="payloadType-input"
name="payloadType"
onChange={(e) => setPayloadType(e.target.value)}
value={payloadType}
>
<FormControlLabel value="kg" control={<Radio />} label="Kg" />
<FormControlLabel value="pound" control={<Radio />} label="Pound" />
</RadioGroup>
<FormLabel id="payloadWeight-input" sx={{ textAlign: 'start', mt: 1 }}>
Weight
</FormLabel>
<TextField
aria-labelledby="payloadWeight-input"
name="payloadWeight"
size='small'
type="number"
fullWidth
value={payloadWeight}
onChange={(e) => setPayloadWeight(e.target.value)}
InputProps={{
endAdornment: <InputAdornment sx={{ width: 12 }} position="end">{adornmentLabel}</InputAdornment>,
}}
/>
<Button type="submit" variant="contained" sx={{ mt: 3 }}>Submit</Button>
</FormControl>
</form>
</>
)
}
export default OrderForm
I like this approach more. The above code could result the same behavior as before with one re-render. We have omitted the useState
and use standard variable to save the value derived from payloadType
. But this approach is not very good because this could lead into performance issue.
By processing a variable directly inside component, for every re-render, the process is executed. ReactJS components always be rendered many times, especially in large scale application. If the process you do is heavy and the component re-rendered many times, than we gonna have serious performance issue.
In order to limit processing variable, we can use useMemo
hook. The hook can prevent the processing from happening by saving the result. useMemo
also receives dependency array. The dependency array will make sure the process is executed only if array elements are changed.
We can implement it like this,
// ...
function OrderForm() {
const [payloadType, setPayloadType] = React.useState<string>('kg')
const [payloadWeight, setPayloadWeight] = React.useState<string>('0')
// Take a look
const adornmentLabel = React.useMemo<string>(
() => payloadType === 'pound' ? 'lb' : 'kg',
[payloadType]
)
// ...
useMemo
receives two parameter. The first one is the function to process variable value and the second one is dependency array. By using useMemo
, the adornmentLabel
is changed only if payloadType
is changed. We can put heavy process inside the function and it is only executed when elements inside dependency array are changed.
Closing
So that's all from me. What do you think of this approach? Let me know your thoughts :D
If you like my article, make sure to click the ❤️ button.
Top comments (0)