The Bug
After release, users reported losing text while typing. The issue was impossible to reproduce at normal typing speeds—it only surfaced under rapid input.
We had a text area that synced with a persistent store. Type something, it saves. Refresh the page, it’s still there. Simple. Except sometimes, mid-sentence, the text would snap back to an earlier version.
This is the story of a race condition hiding in plain sight.
The Buggy Code
The component looked innocent enough:
function TextArea({ savedValue, onSave }) {
const [value, setValue] = useState(savedValue);
const [isDirty, setIsDirty] = useState(false);
// Sync from store when not editing
useEffect(() => {
if (!isDirty) setValue(savedValue);
}, [savedValue, isDirty]);
// Reset dirty flag when values match
useEffect(() => {
if (savedValue === value) setIsDirty(false);
}, [savedValue, value]);
const handleChange = (e) => {
setValue(e.target.value);
setIsDirty(true);
onSave(e.target.value); // debounced at 500ms
};
}
The logic seemed sound:
- Track whether the user is actively editing (
isDirty) - Only accept external updates when not editing
- Clear the dirty flag when the saved value catches up
But under rapid input, users lost their work.
The Race Condition
Here’s how it unfolds:
- User types “Hello” →
value = "Hello",isDirty = true, debounced save queued - User keeps typing “Hello World” →
value = "Hello World", new save queued - First save completes →
savedValueupdates to “Hello” - Second effect fires:
savedValue ("Hello") === value ("Hello World")? No, but theisDirtystate change triggers a re-render - During the re-render cascade,
isDirtybriefly becomesfalse(values matched at some point) - First effect fires:
!isDirtyis true →setValue(savedValue)overwrites “Hello World” with “Hello”
The user’s latest input is gone.
The core flaw: using state for isDirty causes re-renders, and the “reset when values match” logic creates a timing window where stale data can slip through.
Why This Is Hard to Catch
This bug has several properties that make it elusive:
- Timing-dependent — Only happens when saves complete while the user is still typing
- Speed-dependent — Normal typing is usually fine; rapid input triggers it
- Looks correct in isolation — Each effect makes sense on its own
- No errors — React doesn’t warn you; the component “works”
The bug lives in the interaction between pieces, not in any single piece.
The Fix
function TextArea({ savedValue, onSave }) {
const [value, setValue] = useState(savedValue);
const isDirtyRef = useRef(false); // no re-render on change
const lastSentRef = useRef(savedValue); // track what we sent
// Only accept external updates when not dirty AND not an echo of our own save
useEffect(() => {
const isEcho = savedValue === lastSentRef.current;
if (!isDirtyRef.current && !isEcho) {
setValue(savedValue);
}
}, [savedValue]);
// Clear dirty flag only when our save is confirmed
useEffect(() => {
if (savedValue === lastSentRef.current) {
isDirtyRef.current = false;
}
}, [savedValue]);
const handleChange = (e) => {
const newValue = e.target.value;
setValue(newValue);
isDirtyRef.current = true;
lastSentRef.current = newValue;
onSave(newValue);
};
}
What Changed
- Ref instead of state — Dirty tracking doesn’t trigger re-renders, breaking the cascade
- Echo detection — Ignore updates that are just our own saves bouncing back
- Completion-based clearing — Only clear
isDirtywhensavedValuematches what we last sent, confirming the save completed
Why Both Patterns?
Why keep isDirtyRef if we have echo detection?
Because echo detection only blocks our own saves bouncing back. External updates (from another tab, another user, or a sync) are legitimate changes—we still need to block those while the user is actively typing.
The two mechanisms handle different cases:
- Echo detection → “Ignore my own saves coming back”
- Dirty flag → “Ignore external changes while I’m typing”
The General Pattern
This bug appears whenever you have:
- Two-way sync between local state and external state
- Async updates that complete out of order
- State-based flags that trigger re-renders
The fix pattern:
- Use refs for coordination flags — Avoid re-renders for tracking state
- Track what you sent — Distinguish your own updates from external ones
- Clear flags on confirmation, not on match — “Values equal” doesn’t mean “my save completed”
The Lesson
A system that is fast, reactive, and correct in isolation can still fail when those pieces interact—not because any single part is wrong, but because the boundaries between them are undefined.
In reactive systems, correctness depends less on individual components and more on how they interact. The bug wasn’t in the store, or the effect, or the state. It was in the space between them.
This bug occurred while building a draft system with TinyBase and IndexedDB. Read about that architecture →
Read more