I am building a Mortgage calculator, where a user can enter either Down-Payment amount or percentage and either one will update the other. After struggling with implementation for some time I eventually figured out how to get this working right. In this post, I'll explain the problem and my solution to hopefully save you time and if you have to implement something similar.
The Problem: State Updates Triggering State Updates
I want to update the other field whenever one of them changes. For example, when the user changes the down payment amount, I want to update the down payment percentage to reflect the new amount, and vice versa.
Here's a simplified sample of the code that I implemented for this functionality:
const [input, setInput] = useState({
down: 0,
downPercent: 0,
propertyPrice: 0,
});
// changing down-payment amount calculates percentage
useEffect(() => {
if (input.propertyPrice > 0) {
setInput({
...input,
downPercent:
Math.round((input.down / input.propertyPrice) * 100 * 10)/ 10,
});
}
}, [input.down, input.propertyPrice]);
//^ dependencies that this useEffect is watching for
// changing percentage recalculates down-payment amount
useEffect(() => {
if (input.propertyPrice > 0) {
setInput({
...input,
down: Math.round((input.downPercent / 100) * input.propertyPrice),
});
}
}, [input.downPercent, input.propertyPrice]);
//^ dependencies that this useEffect is watching for
return (
<div>
<label>
Down Payment Amount:
<input type="number" value={input.down} onChange={(e) => setInput({ ...input, down: e.target.value })} />
</label>
<label>
Down Payment Percentage:
<input type="number" value={input.downPercent} onChange={(e) => setInput({ ...input, downPercent: e.target.value })} />
</label>
</div>
);
The useEffect
hooks update the input
state whenever the down
or downPercent
fields change. However, this code has a problem: it creates an infinite loop of state updates. Here's what happens:
The user changes the
down
field by entering a new down payment amount.The
useEffect
hook that recalculates thedownPercent
field is triggered becauseinput.down
has changed.The
useEffect
hook updates theinput
state to set the newdownPercent
value.Setting the
input
state triggers a re-render of the component.The
useEffect
hook that recalculates thedown
field is triggered becauseinput.downPercent
has changed.The
useEffect
hook updates theinput
state to set the newdown
value.Setting the
input
state triggers a re-render of the component.Go to step 2.
This creates an infinite loop of updates that will continue until the application crashes or becomes unresponsive (in my case unresponsiveness was occurring in the form of React skipping a step and updating the current input when another entry is made, and it feels like its 1 step behind the input).
The Solution: Trigger State Updates Outside of useEffect
The solution to this problem is to trigger state updates outside of the useEffect
hooks. This can be done by setting the editing
state when the user changes a field, and then checking the editing
state in the useEffect
hooks to determine whether or not to update the other field.
Here's the modified code that implements this solution:
const [input, setInput] = useState({
down: 0,
downPercent: 0,
propertyPrice: 0,
});
//state to control which input is being edited
const [editing, setEditing] = useState('');
//changing down-payment amount calculates percentage
useEffect(() => {
if (input.propertyPrice > 0 && editing === 'amount') {
setInput({
...input,
downPercent:
Math.round((input.down / input.propertyPrice) * 100 * 10) / 10,
});
}
}, [input.down, input.propertyPrice]);
//^ dependencies that this useEffect is watching for
//changing percentage recalculates down-payment amount
useEffect(() => {
if (input.propertyPrice > 0 && editing === 'percent') {
setInput({
...input,
down: Math.round((input.downPercent / 100) * input.propertyPrice),
});
}
}, [input.downPercent, input.propertyPrice]);
//^ dependencies that this useEffect is watching for
return (
<div>
<label>
Down Payment Amount:
<input type="number" value={input.down} onChange={(e) => { setInput({ ...input, down: e.target.value }); setEditing('amount'); }} />
</label>
<label>
Down Payment Percentage:
<input type="number" value={input.downPercent} onChange={(e) => { setInput({ ...input, downPercent: e.target.value }); setEditing('percent'); }} />
</label>
</div>
);
The key changes are:
I added a new state variable,
editing
, which tracks which field the user is currently editing.I modified the
onChange
handlers for theinput
elements to set theediting
state to'amount'
or'percent'
depending on which field was changed.I added a check for the
editing
state in theuseEffect
hooks to determine whether or not to update the other field.
With these changes, the infinite loop of state updates is avoided, and the component behaves as expected.
I hope this post helps you if you run into a similar issue. Happy coding!