React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

focusblurform-validationaccessibilityfocus-trapreact-19typescript

Focus Events

Track when elements gain or lose focus for form validation, accessibility, and UI state management.

Event Reference

EventFires WhenBubblesTypical Elements
onFocusElement receives focusYes (unlike native focus)<input>, <textarea>, <select>, <button>, <a>, any element with tabIndex
onBlurElement loses focusYes (unlike native blur)Same as above
onFocusCaptureSame as onFocus, but fires during capture phaseCaptureSame as above
onBlurCaptureSame as onBlur, but fires during capture phaseCaptureSame as above

React's onFocus and onBlur bubble by default, matching the native focusin/focusout behavior -- not native focus/blur which do not bubble.

Recipe

Quick-reference recipe card -- copy-paste ready.

// Validate on blur, highlight on focus
function ValidatedInput() {
  const [error, setError] = useState<string | null>(null);
 
  const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
    const value = e.currentTarget.value.trim();
    setError(value.length === 0 ? "This field is required" : null);
  };
 
  return (
    <div>
      <input
        onFocus={() => setError(null)}
        onBlur={handleBlur}
        className={error ? "border-red-500" : "border-gray-300"}
      />
      {error && <p className="text-red-500 text-sm mt-1">{error}</p>}
    </div>
  );
}

When to reach for this: You need inline validation that runs after the user leaves a field, focus ring styling for accessibility, or tracking which element currently has focus.

Working Example

// components/ValidatedEmailField.tsx
"use client";
 
import { useState, useRef } from "react";
 
type FieldState = {
  value: string;
  touched: boolean;
  error: string | null;
};
 
function validateEmail(email: string): string | null {
  if (email.trim().length === 0) return "Email is required";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Invalid email format";
  return null;
}
 
export default function ValidatedEmailField() {
  const [field, setField] = useState<FieldState>({
    value: "",
    touched: false,
    error: null,
  });
  const [isFocused, setIsFocused] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
 
  const handleFocus: React.FocusEventHandler<HTMLInputElement> = () => {
    setIsFocused(true);
  };
 
  const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
    setIsFocused(false);
    const error = validateEmail(e.currentTarget.value);
    setField((prev) => ({ ...prev, touched: true, error }));
  };
 
  const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
    const value = e.currentTarget.value;
    setField((prev) => ({
      ...prev,
      value,
      // Re-validate on change only if the field was already touched
      error: prev.touched ? validateEmail(value) : null,
    }));
  };
 
  const ringClass = isFocused
    ? "ring-2 ring-blue-500 border-blue-500"
    : field.error
      ? "border-red-500"
      : "border-gray-300";
 
  return (
    <form
      className="max-w-sm mx-auto p-6"
      onSubmit={(e) => {
        e.preventDefault();
        const error = validateEmail(field.value);
        if (error) {
          setField((prev) => ({ ...prev, touched: true, error }));
          inputRef.current?.focus();
          return;
        }
        alert(`Submitted: ${field.value}`);
      }}
    >
      <label htmlFor="email" className="block text-sm font-medium mb-1">
        Email
      </label>
      <input
        ref={inputRef}
        id="email"
        type="email"
        value={field.value}
        onChange={handleChange}
        onFocus={handleFocus}
        onBlur={handleBlur}
        aria-invalid={!!field.error}
        aria-describedby={field.error ? "email-error" : undefined}
        className={`w-full px-3 py-2 border rounded ${ringClass}`}
      />
      {field.touched && field.error && (
        <p id="email-error" role="alert" className="text-red-500 text-sm mt-1">
          {field.error}
        </p>
      )}
      <button
        type="submit"
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Submit
      </button>
    </form>
  );
}

What this demonstrates:

  • Validating on blur so users are not interrupted mid-typing
  • Clearing errors on focus so the user gets a fresh start
  • Re-validating on change only after the field has been touched once
  • Using aria-invalid and aria-describedby for screen reader accessibility
  • Programmatic focus via inputRef.current?.focus() on submit error

Deep Dive

How It Works

  • React wraps native focusin/focusout events as onFocus/onBlur, which means they bubble through the React tree. This is intentional -- it lets a parent <form> or <div> listen for focus changes on any descendant.
  • The FocusEvent object includes relatedTarget, which references the element that is gaining focus (on blur) or losing focus (on focus). This lets you detect focus direction.
  • Capture-phase variants (onFocusCapture, onBlurCapture) fire before the target element's handler, useful for intercepting focus in wrapper components.

Variations

Focus-within pattern (parent reacts to child focus):

function FieldGroup() {
  const [hasFocusWithin, setHasFocusWithin] = useState(false);
 
  return (
    <div
      onFocus={() => setHasFocusWithin(true)}
      onBlur={(e) => {
        // Only clear if focus is leaving the container entirely
        if (!e.currentTarget.contains(e.relatedTarget as Node)) {
          setHasFocusWithin(false);
        }
      }}
      className={hasFocusWithin ? "ring-2 ring-blue-300 rounded p-4" : "p-4"}
    >
      <input placeholder="First name" className="block mb-2 border px-2 py-1" />
      <input placeholder="Last name" className="block border px-2 py-1" />
    </div>
  );
}

Auto-focus on mount:

function SearchModal() {
  const inputRef = useRef<HTMLInputElement>(null);
 
  useEffect(() => {
    // Focus after paint to avoid layout thrashing
    requestAnimationFrame(() => {
      inputRef.current?.focus();
    });
  }, []);
 
  return <input ref={inputRef} placeholder="Search..." />;
}

Focus trapping in modals:

function FocusTrap({ children }: { children: React.ReactNode }) {
  const trapRef = useRef<HTMLDivElement>(null);
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key !== "Tab") return;
 
    const focusable = trapRef.current?.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (!focusable || focusable.length === 0) return;
 
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
 
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  };
 
  return (
    <div ref={trapRef} onKeyDown={handleKeyDown}>
      {children}
    </div>
  );
}

Using relatedTarget to detect focus direction:

function DirectionalFocus() {
  const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
    const leavingTo = e.relatedTarget as HTMLElement | null;
    if (leavingTo?.dataset.cancel) {
      // User tabbed to cancel -- discard changes
      e.currentTarget.value = "";
    }
  };
 
  return (
    <div>
      <input onBlur={handleBlur} placeholder="Type something" />
      <button data-cancel="true">Cancel</button>
      <button>Save</button>
    </div>
  );
}

Blur with delay for dropdowns (prevent closing on option click):

function Dropdown() {
  const [open, setOpen] = useState(false);
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
 
  const handleFocus = () => {
    clearTimeout(timeoutRef.current);
    setOpen(true);
  };
 
  const handleBlur = () => {
    // Delay closing so click on dropdown option can fire first
    timeoutRef.current = setTimeout(() => setOpen(false), 150);
  };
 
  return (
    <div onFocus={handleFocus} onBlur={handleBlur}>
      <input placeholder="Search..." />
      {open && (
        <ul className="border rounded mt-1 shadow">
          <li className="px-3 py-1 cursor-pointer hover:bg-gray-100">Option A</li>
          <li className="px-3 py-1 cursor-pointer hover:bg-gray-100">Option B</li>
        </ul>
      )}
    </div>
  );
}

TypeScript Notes

// The generic parameter specifies the element type
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
  e.currentTarget; // HTMLInputElement (always the element the handler is on)
  e.target;        // Element (could be a child that triggered the event)
};
 
// relatedTarget is typed as EventTarget | null
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
  const next = e.relatedTarget as HTMLElement | null;
  // Cast is needed because relatedTarget is EventTarget | null
  if (next?.tagName === "BUTTON") {
    // Focus moved to a button
  }
};
 
// Using the shorthand type alias
const onFocus: React.FocusEventHandler<HTMLSelectElement> = (e) => {
  // e is React.FocusEvent<HTMLSelectElement>
};
 
// When listening on a parent container for any child focus
const onContainerFocus = (e: React.FocusEvent<HTMLDivElement>) => {
  // e.target may be an input, button, etc. inside the div
  // e.currentTarget is always the div
};

Gotchas

  • onBlur fires before onClick -- If you have a dropdown that closes on blur and options that use onClick, the blur fires first and unmounts the options before the click registers. Fix: Use onMouseDown with e.preventDefault() on the option to prevent blur, or use setTimeout to delay the blur effect.

  • relatedTarget is null when focus moves outside the document -- When the user tabs out of the browser window or clicks on a non-focusable area, relatedTarget is null. Fix: Always check for null before accessing properties on relatedTarget.

  • React onFocus/onBlur bubble, but native focus/blur do not -- If you attach a native focus listener via addEventListener, it will not bubble. Mixing native and React focus listeners leads to confusing behavior. Fix: Stick to React's synthetic events consistently, or use native focusin/focusout if you must use addEventListener.

  • autoFocus prop causes focus before useEffect runs -- The autoFocus JSX prop focuses the element during the commit phase, before effects run. If your effect depends on knowing what is focused, it may see stale state. Fix: Use a ref callback or requestAnimationFrame inside useEffect to check focus after paint.

  • Calling element.focus() during render causes React warnings -- Imperatively focusing during the render phase triggers side effects. Fix: Always call .focus() inside useEffect, event handlers, or requestAnimationFrame.

  • Focus events fire on every child when using bubbling -- A parent onFocus handler fires every time any focusable child gains focus, not just when focus enters the parent container. Fix: Use e.currentTarget.contains(e.relatedTarget) to distinguish "focus entered the container" from "focus moved between children."

  • tabIndex={-1} makes elements focusable via JS but not Tab key -- Setting tabIndex={-1} allows .focus() calls but removes the element from the tab order. Setting tabIndex={0} adds it to the natural tab order. Fix: Use tabIndex={0} when you want keyboard-navigable elements, tabIndex={-1} only for programmatic focus targets.

Alternatives

AlternativeUse WhenDon't Use When
CSS :focus-withinYou only need visual styling changes on parent when a child is focusedYou need to run JavaScript logic on focus changes
CSS :focus-visibleYou want focus rings only for keyboard users, not mouse clicksYou need to track focus state in React state
document.activeElementYou need to check what is currently focused at a point in timeYou need reactive updates when focus changes
FocusEvent via useEffect + addEventListenerYou need capture-phase focus on document or windowReact synthetic events already cover your use case
Headless UI / Radix focus managementYou need production-grade focus trapping and restoration in modalsYou have a simple single-field validation scenario

FAQs

How does React's onFocus/onBlur differ from native focus/blur events?

React's onFocus and onBlur bubble through the React tree, matching the behavior of native focusin/focusout. Native focus/blur events do not bubble. This means a parent element can listen for focus changes on any descendant.

What is the relatedTarget property on focus events, and what does it tell you?
  • On onBlur, relatedTarget is the element that is gaining focus
  • On onFocus, relatedTarget is the element that is losing focus
  • It is null when focus moves outside the document (e.g., user tabs to another window)
  • Always check for null before accessing properties on it
How do you implement a "focus-within" pattern where a parent reacts to any child gaining focus?
<div
  onFocus={() => setHasFocusWithin(true)}
  onBlur={(e) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      setHasFocusWithin(false);
    }
  }}
>
  <input placeholder="First name" />
  <input placeholder="Last name" />
</div>
Gotcha: Why does my dropdown close before the option click registers?

onBlur fires before onClick. When your dropdown closes on blur, it unmounts the options before the click event fires. Fix this by using onMouseDown with e.preventDefault() on the options to prevent blur, or use setTimeout to delay closing.

What is the difference between tabIndex={0} and tabIndex={-1}?
  • tabIndex={0} adds the element to the natural tab order, making it keyboard-navigable
  • tabIndex={-1} makes the element focusable via JavaScript (.focus()) but removes it from the tab order
  • Use 0 for interactive elements users should reach via Tab; use -1 for programmatic focus targets only
How do you validate a field on blur but clear the error on focus?
<input
  onFocus={() => setError(null)}
  onBlur={(e) => {
    const value = e.currentTarget.value.trim();
    setError(value.length === 0 ? "Required" : null);
  }}
/>
Gotcha: Why does autoFocus cause stale state in my useEffect?

The autoFocus prop focuses the element during the commit phase, before effects run. If your useEffect checks what is focused, it may see stale state. Use a ref callback or requestAnimationFrame inside useEffect to check focus after paint.

How do you implement focus trapping in a modal dialog?

Query all focusable elements inside the modal, then on Tab keydown redirect focus from the last element back to the first (and vice versa with Shift+Tab). Use querySelectorAll with the selector 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'.

When should you use CSS :focus-visible instead of React focus events?
  • Use CSS :focus-visible when you only need visual styling (focus rings) for keyboard users, not mouse clicks
  • Use React onFocus/onBlur when you need to run JavaScript logic or track focus state in React state
  • Use CSS :focus-within when you only need parent styling changes on child focus
Why does a parent onFocus fire repeatedly when focus moves between its children?

Because React's onFocus bubbles, it fires every time any focusable child gains focus. Use e.currentTarget.contains(e.relatedTarget as Node) to distinguish "focus entered the container" from "focus moved between children."

What is the correct TypeScript type for a focus event handler, and how do you type relatedTarget?
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
  e.currentTarget; // HTMLInputElement
  // relatedTarget is typed as EventTarget | null
  const next = e.relatedTarget as HTMLElement | null;
  if (next?.tagName === "BUTTON") { /* ... */ }
};
How do you use the React.FocusEventHandler shorthand type in TypeScript?
const onFocus: React.FocusEventHandler<HTMLSelectElement> = (e) => {
  // e is React.FocusEvent<HTMLSelectElement>
  e.currentTarget; // HTMLSelectElement
};
  • Keyboard Events -- Handling key presses for accessibility and shortcuts
  • Form Events -- onChange, onSubmit, and controlled form patterns
  • Mouse Events -- Click, hover, and pointer interactions