DEV Community

nareshipme
nareshipme

Posted on

Syncing Controlled Inputs with External State in React (Without useEffect or setState-in-render)

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
  • useEffect to sync state feels hacky and fires at the wrong time
  • setState during 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))}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  • useEffect runs 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
}
Enter fullscreen mode Exit fullscreen mode

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)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)