Interdependent Inputs With Rollback
by Pratik Rai
Read the full write-upFour fields — a number, a select, a range and a number — that constrain each other: each must not exceed the one to its right.
An edit that would break the chain does not get clamped or flagged. It rolls back to the value of its parent, the field to its right.
Requirements
Enforce
floor ≤ target ≤ ceiling ≤ capat all times.An edit that would break the chain rolls that field back to its parent’s value.
Lowering a field also pulls the fields to its left down with it, so the chain never breaks from the other direction.
Non-numeric or empty input is treated as corrupt and rolls back too.
Show which field was rolled back —
.field-invalidand.rolledare styled for it.
Notes
The four different input types are deliberate: a range fires continuously while dragged, which is where a naive implementation fights the user.
Rolling back is not the same as clamping. Both keep the chain valid; only one is what was asked for.
Hints
Interdependent Inputs With Rollback (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3// Order matters: each field must be <= the next one along.
4const FIELDS = ['floor', 'target', 'ceiling', 'cap'];
5
6// The "parent" of a field is the one to its right, which is the value a
7// rejected edit rolls back to.
8const PARENT_OF = { floor: 'target', target: 'ceiling', ceiling: 'cap' };
9
10export default function App() {
11 const [values, setValues] = useState({ floor: 10, target: 30, ceiling: 60, cap: 100 });
12 const [rolledBack, setRolledBack] = useState(null);
13
14 const commit = (field, raw) => {
15 const next = Number(raw);
16
17 // Nonsense input is its own kind of corrupt: roll back rather than
18 // storing NaN and letting every comparison below silently become false.
19 if (!Number.isFinite(next)) {
20 setRolledBack(field);
21 return;
22 }
23
24 const parent = PARENT_OF[field];
25 // Cap has no parent, so nothing above it can reject it. Everything else is
26 // checked against the field to its right.
27 if (parent && next > values[parent]) {
28 setValues((current) => ({ ...current, [field]: current[parent] }));
29 setRolledBack(field);
30 return;
31 }
32
33 // A field is also constrained from below: raising Cap is always fine, but
34 // lowering it below Ceiling would break the chain the other way, so the
35 // fields to the left follow it down.
36 setValues((current) => {
37 const updated = { ...current, [field]: next };
38 const index = FIELDS.indexOf(field);
39 for (let i = index - 1; i >= 0; i -= 1) {
40 const left = FIELDS[i];
41 const right = FIELDS[i + 1];
42 if (updated[left] > updated[right]) updated[left] = updated[right];
43 }
44 return updated;
45 });
46 setRolledBack(null);
47 };
48
49 const fieldClass = (field) => 'field' + (rolledBack === field ? ' field-invalid' : '');
50
51 return (
52 <div className="form">
53 <h1>Thresholds</h1>
54
55 <div className={fieldClass('floor')}>
56 <label htmlFor="floor">Floor</label>
57 <span className="rule">Must not exceed Target</span>
58 <input
59 id="floor"
60 type="number"
61 value={values.floor}
62 onChange={(e) => commit('floor', e.target.value)}
63 />
64 {rolledBack === 'floor' && <span className="rolled">Rolled back to Target.</span>}
65 </div>
66
67 <div className={fieldClass('target')}>
68 <label htmlFor="target">Target</label>
69 <span className="rule">Must not exceed Ceiling</span>
70 <select
71 id="target"
72 value={values.target}
73 onChange={(e) => commit('target', e.target.value)}
74 >
75 {[10, 20, 30, 40, 50, 60, 70, 80].map((n) => (
76 <option key={n} value={n}>{n}</option>
77 ))}
78 </select>
79 {rolledBack === 'target' && <span className="rolled">Rolled back to Ceiling.</span>}
80 </div>
81
82 <div className={fieldClass('ceiling')}>
83 <label htmlFor="ceiling">Ceiling</label>
84 <span className="rule">Must not exceed Cap</span>
85 <input
86 id="ceiling"
87 type="range"
88 min="0"
89 max="100"
90 value={values.ceiling}
91 onChange={(e) => commit('ceiling', e.target.value)}
92 />
93 {rolledBack === 'ceiling' && <span className="rolled">Rolled back to Cap.</span>}
94 </div>
95
96 <div className={fieldClass('cap')}>
97 <label htmlFor="cap">Cap</label>
98 <span className="rule">The upper bound</span>
99 <input
100 id="cap"
101 type="number"
102 value={values.cap}
103 onChange={(e) => commit('cap', e.target.value)}
104 />
105 </div>
106
107 <p className="readout">
108 {FIELDS.map((f) => f + ': ' + values[f]).join(' · ')}
109 </p>
110 </div>
111 );
112}How it works
This one exposes shaky state modelling faster than almost anything else, which is presumably why it was used. Four fields with pairwise constraints invite four ad-hoc if statements, and the version that results is correct for the case the candidate was thinking about and wrong for the other three.
Naming the relationship as data fixes that. PARENT_OF says each field is bounded by the one to its right, and one rule then covers all of them — including the fact that cap is unbounded, which falls out of it having no parent rather than needing its own branch.
The direction most people miss is downward. Rolling back handles someone raising Floor past Target. It does nothing about someone dragging Cap down below Ceiling, which breaks the same chain from the other end. Walking left from the edited field and pulling anything now too large down with it covers that in three lines.
Number.isFinite before any comparison is the guard that matters most and reads like a formality. Number("abc") is NaN, and NaN > anything is false — so a corrupt value passes every bound check and lands in state, after which the whole form compares against NaN and silently stops enforcing anything.
The range input is in the requirements on purpose: it fires change continuously while dragged, so any implementation that fights the user or lags behind shows up immediately rather than only under a careful test.