React Events Best Practices
A condensed summary of the 25 most important best practices drawn from every page in this section.
- preventDefault Before setData on Copy: Calling
e.clipboardData.setData(...)inonCopywithoute.preventDefault()lets the browser silently overwrite your custom payload with the selection; always prevent default first, then write every format you want (e.g.,text/plainandtext/html). - Make Non-input Paste Targets Focusable:
<div>and other non-input elements never receiveonPasteunless they hold focus, so addtabIndex={0}(or-1) plus an outline-safe focus style to make custom paste zones actually work. - preventDefault on dragover to Opt Into Drop: An element is not a valid drop target until
onDragOvercallse.preventDefault(); without it the cursor shows "not allowed" andonDropnever fires, which is the single most common HTML5 drag bug. - Read dataTransfer Only at dragstart/drop: For security,
dataTransfer.getData()returns""duringdragover/dragenter; onlydataTransfer.typesis inspectable during the drag, so feature-gate on types and read the payload inonDrop. - Don't Use Native DnD on Mobile: HTML5 drag-and-drop is effectively broken on mobile browsers, so switch to a library like
dnd-kitor build custom pointer-event logic when touch support matters. - Detect Real Focus Exit With contains: Use
e.currentTarget.contains(e.relatedTarget as Node)to tell "focus moved between children" from "focus truly left the container" —onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget)) closeDropdown() }}— rememberrelatedTargetisnullwhen focus leaves the document entirely. - React Focus Events Bubble: Unlike native
focus/blur, React'sonFocusandonBlurare wired tofocusin/focusoutand bubble up the tree — so a parent sees every child focus change, and mixing React handlers withaddEventListener("focus", …)yields inconsistent behavior. - Use onMouseDown for Click-After-Blur: When a dropdown dismisses on
onBlurbefore a click on its option registers, swap the option's handler toonMouseDown+e.preventDefault()so the click is captured before focus moves. - onChange Fires Every Keystroke: React's
onChangemaps to the nativeinputevent, not nativechange, so it fires on every keystroke instead of on blur; useonBlur(oruseDeferredValue) when you genuinely want debounced commit semantics. - File Inputs Are Always Uncontrolled: Browsers forbid setting
valueon<input type="file">, so read files throughonChange—onChange={e => setFile(e.target.files?.[0])}— and a ref, and never try to control the value; clearing means assigning""to the DOM node directly viainputRef.current!.value = "". - Don't Mix action With onSubmit: React 19's form
actionprop plususeActionStatebypassesonSubmitentirely (nopreventDefaultneeded); combining the two on the same form produces confusing double-handling, so pick one model per form. - Use onKeyDown, Not onKeyPress:
onKeyPressis deprecated and never fires for Escape, arrows, Tab, or modifiers; useonKeyDownfor every keyboard interaction including shortcuts and accessible widget navigation. - e.key vs e.code:
e.keyis layout- and modifier-aware ("A"with Shift) and is what you want for shortcuts;e.codeis the physical position ("KeyA") and is what you want for WASD-style games where layout should not matter. - Guard Against e.repeat:
onKeyDownfires continuously while a key is held withe.repeat === true, so early-return on repeat —onKeyDown={e => { if (e.repeat) return; sendMessage() }}— for expensive handlers like network calls or state transitions you only want once per press. - Filter onTransitionEnd by propertyName:
onTransitionEndfires once per animated CSS property, so a transition on bothopacityandtransformdispatches twice; guard handlers:onTransitionEnd={e => { if (e.propertyName !== "opacity") return; onFadeComplete() }}. - Don't Use onTimeUpdate for Frame-Accurate UI: Browsers throttle
onTimeUpdateto roughly four times per second, which is too coarse for progress scrubs or waveform highlights — drive those fromrequestAnimationFramereadingvideo.currentTimeinstead. - onMouseEnter/Leave Don't Bubble:
onMouseEnter/onMouseLeavefire only on the exact element, whileonMouseOver/onMouseOutbubble and re-fire on every child crossing; mixing them causes flicker on nested hover regions, so pick enter/leave for hover UIs. - Throttle onMouseMove With rAF:
onMouseMovefires on every pixel of movement and can trigger re-render storms; write to a ref and schedule updates withrequestAnimationFrame, or accept that raw state updates will tank frame rate. - Call setPointerCapture Synchronously: To keep a drag alive when the pointer leaves the element at high velocity, call it synchronously in
onPointerDown—onPointerDown={e => e.currentTarget.setPointerCapture(e.pointerId)}— calling it later or from an effect is too late to redirect subsequent events. - Set touch-action: none for Pointer Drag: Passive-default touch scrolling still fights your drag unless you disable it in CSS; add
touch-action: noneon the draggable element so the browser yields the gesture to your pointer handlers. - onPointerLeave Still Fires During Capture: Active pointer capture does not suppress
onPointerLeave, so useonGotPointerCapture/onLostPointerCaptureto track real capture state instead of inferring it from enter/leave events. - Page Scroll Needs a window Listener:
onScrollon the root or body only fires when that element itself has a scrollbar, so useuseEffect—useEffect(() => { const h = () => {}; window.addEventListener("scroll", h, { passive: true }); return () => window.removeEventListener("scroll", h); }, [])— and always pass{ passive: true }to keep compositor-thread scrolling fast. - Prefer IntersectionObserver Over onScroll:
IntersectionObserverruns off the main thread and is much cheaper than computing visibility fromonScrollmath; use it for lazy-load, infinite scroll, and "scrolled past header" style logic, and guard withisIntersectingsince it fires immediately on observe. - Use changedTouches in onTouchEnd: In
onTouchEndthetouchesandtargetToucheslists are empty (the finger that triggered the event has already lifted), so always reade.changedTouches[0]to get the final position and convert withArray.from(e.changedTouches)sinceTouchListis not an array. - React Touch Listeners Are Passive: React 17+ registers touch handlers as passive at the delegation root, so
e.preventDefault()insideonTouchMovesilently fails; either attach a non-passive listener viaref.addEventListener("touchmove", h, { passive: false })or applytouch-action: noneand let CSS block the gesture.