Syncing Controlled Inputs with External State in React (Without useEffect or setState-in-render)
If you've ever built a numeric input in React that syncs with an external source (a database, a parent prop, a WebSocket), you've probably fought this battle:
- User types → local state updates
- External update arrives → you need to reflect it without blowing away what the user is mid-typing
-
useEffectto sync state feels hacky and fires at the wrong time -
setStateduring render is a React warning waiting to happen
Here's a pattern that solves all of this cleanly.
The Problem
Consider a time-editing input where the value comes from a prop (synced from a database), but the user can also type freely:
// ❌ The naive approach — breaks when prop updates mid-edit
function TimeInput({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const [display, setDisplay] = useState(value.toFixed(1));
// This runs AFTER every render — causes flicker and double-render
useEffect(() => {
setDisplay(value.toFixed(1));
}, [value]);
return (
<input
value={display}
onChange={(e) => setDisplay(e.target.value)}
onBlur={(e) => onChange(parseFloat(e.target.value))}
/>
);
}
Problems:
-
useEffectruns after render, causing a visible flicker when the prop changes - If the user is mid-edit, the effect stomps on their input
- It's two renders for every external update
Some people reach for refs to detect changes and call setState during render:
// ❌ setState during render — React warns about this
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setDisplay(value.toFixed(1)); // ⚠️ Setting state during render
}
This avoids the flicker, but React considers calling setState during render a side effect and it can cause subtle bugs in concurrent mode.
The Fix: Focus-Tracked Edit State
The key insight: you only need local state while the field is focused. When the user isn't editing, just derive the display value directly from the prop.
// ✅ Clean pattern — no effects, no setState-in-render
function TimeInput({ value, onChange }: { value: number; onChange: (v: number) => void }) {
// null = not editing; string = user is currently typing
const [editValue, setEditValue] = useState<string | null>(null);
// While focused: show what the user typed. Otherwise: show the prop.
const display = editValue ?? value.toFixed(1);
function handleBlur(raw: string) {
setEditValue(null); // Exit edit mode → display reverts to prop
const v = parseFloat(raw);
if (!isNaN(v) && v !== value) {
onChange(v);
}
}
return (
<input
value={display}
onChange={(e) => setEditValue(e.target.value)}
onBlur={(e) => handleBlur(e.target.value)}
/>
);
}
What this gives you:
| Scenario | Behavior |
|---|---|
| User types |
editValue holds the in-progress string |
| External prop updates (DB round-trip) |
display updates immediately if not focused |
| External prop updates while user is editing | User's input is preserved |
| User blurs with invalid input |
editValue clears, display reverts to prop |
Real-World Example
Here's how this looks for a clip timing editor that syncs with a backend:
export function ClipTimingEditor({ clip, videoRef, onClipAction }: ClipTimingEditorProps) {
const [startEdit, setStartEdit] = useState<string | null>(null);
const [endEdit, setEndEdit] = useState<string | null>(null);
// When not editing: display is derived from the live prop (no stale state)
const startDisplay = startEdit ?? clip.start_sec.toFixed(1);
const endDisplay = endEdit ?? clip.end_sec.toFixed(1);
function commitStart(raw: string) {
setStartEdit(null); // Exit edit mode first
const v = parseFloat(raw);
if (isNaN(v)) return; // Revert gracefully — display falls back to prop
const clamped = Math.max(0, Math.min(v, clip.end_sec - 0.5));
if (clamped !== clip.start_sec) onClipAction(clip.id, { start_sec: clamped });
}
function commitEnd(raw: string) {
setEndEdit(null);
const v = parseFloat(raw);
if (isNaN(v)) return;
let clamped = Math.max(clip.start_sec + 0.5, v);
if (videoRef.current?.duration) clamped = Math.min(clamped, videoRef.current.duration);
if (clamped !== clip.end_sec) onClipAction(clip.id, { end_sec: clamped });
}
return (
<div className="flex gap-2">
<input
type="number"
value={startDisplay}
onChange={(e) => setStartEdit(e.target.value)}
onFocus={(e) => setStartEdit(e.target.value)}
onBlur={(e) => commitStart(e.target.value)}
/>
<input
type="number"
value={endDisplay}
onChange={(e) => setEndEdit(e.target.value)}
onFocus={(e) => setEndEdit(e.target.value)}
onBlur={(e) => commitEnd(e.target.value)}
/>
</div>
);
}
When the backend confirms the update and sends back the new clip prop, the display updates instantly — unless the user is still editing, in which case their in-progress value is preserved.
Why Not Just Uncontrolled Inputs?
You could use defaultValue + a ref, but then you lose the ability to programmatically update the input when the prop changes (e.g., another user edits the same clip, or you undo). The focus-tracked pattern gives you controlled input behavior with zero extra effects.
Summary
-
null= derived mode: display comes from the prop directly -
string= edit mode: display comes from local state while user types -
onFocus→ enter edit mode (seed with current display value) -
onBlur→ commit + exit edit mode - External prop changes reflect immediately when not focused — no effects needed
This pattern works anywhere you have controlled inputs that need to stay in sync with an external truth source without fighting the user while they're mid-edit.
Have a different approach? Drop it in the comments — curious how others handle this.
Top comments (0)