Uncontrolled SearchInput: Solving the Focus Loss Problem in React
React

Uncontrolled SearchInput: Solving the Focus Loss Problem in React

Ihor (Harry) ChyshkalaIhor (Harry) Chyshkala

Audio Narration

Uncontrolled SearchInput: Solving the Focus Loss Problem in React

0:000:00
AI-generated audio narration

Uncontrolled SearchInput: A Detailed Explanation of Solving the Focus Loss Problem

Have you ever experienced the frustration of typing in a search field, only to have the cursor disappear after each keystroke? This article explains why this happens and how to fix it using uncontrolled components in React.

Table of Contents

1. The Problem: Focus Loss When Typing

2. React Rendering Cycle: How It Works

3. Controlled vs Uncontrolled Components

4. Why the Problem Occurred

5. How Uncontrolled Input Solved the Problem

6. Real-World Analogies

7. Technical Implementation

The Problem: Focus Loss When Typing

Symptoms

When a user tried to type in the search field:

1. Types the first letter - everything works fine

2. Tries to type the second letter - cursor disappears from the field

3. Has to click on the field again

4. Types the next letter - focus lost again

5. And so on, constantly...

Why is this a critical problem?

- Impossible to use the search normally

- Poor UX (user experience)

- Users get frustrated and abandon the feature

React Rendering Cycle: How It Works

React operates on the principle of reactivity: when state changes, React automatically re-renders the component.

Component Lifecycle

// 1. INITIAL RENDER
// - React creates the component
// - Calls the component function
// - Creates Virtual DOM
// - Renders to the real DOM

// 2. USER INTERACTION
// - User types text
// - onChange handler is called
// - State updates: setSearch('new text')

// 3. RE-RENDER
// - React sees that state changed
// - Calls the component function AGAIN
// - Creates a NEW Virtual DOM
// - Compares with the previous (reconciliation)
// - Updates ONLY what changed

What happens during a re-render?

IMPORTANT: During a re-render, React:

- PRESERVES refs (useRef)

- PRESERVES memoized values (useMemo, useCallback)

- DOES NOT preserve DOM state (focus, cursor position) if the component is recreated

- MAY LOSE focus if the element was "replaced" in the DOM

Controlled vs Uncontrolled Components

Controlled Component

Definition: A component where React controls the input value through state.

function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}              // React controls the value
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

The controlled input cycle:

User types 'a'
      |
onChange is called with event
      |
setValue('a') updates state
      |
COMPONENT RE-RENDERS
      |
React renders <input value="a" />
      |
DOM INPUT IS RECREATED OR UPDATED
      |
FOCUS MAY BE LOST (if input is in a parent that also re-renders)

Uncontrolled Component

Definition: A component where the DOM itself stores and controls its value.

function UncontrolledInput() {
  const inputRef = useRef(null);

  return (
    <input
      ref={inputRef}           // Access to DOM element
      onChange={(e) => {
        // Handle changes, but DO NOT control value
        console.log(e.target.value);
      }}
      // NO value prop!
    />
  );
}

The uncontrolled input cycle:

User types 'a'
      |
DOM updates its internal value (native browser behavior)
      |
onChange is called with event
      |
We process the event (e.g., debounce)
      |
setState is NOT called for input value
      |
NO RE-RENDER HAPPENS
      |
FOCUS STAYS

Comparison Table

Controlled: React controls value via state, value prop required, always re-renders on change, may lose focus

Uncontrolled: DOM controls value natively, no value prop, no re-render (unless other state changes), focus preserved

Why the Problem Occurred

Old Implementation (Problematic)

function Contacts() {
  const [search, setSearch] = useState('');         // State for search
  const [contacts, setContacts] = useState([]);     // State for contacts
  const [page, setPage] = useState(1);              // State for pagination

  // Load contacts
  const fetchContacts = useCallback(() => {
    // ... API request
    setContacts(data);  // Update state
  }, [search, page]);   // Depends on search

  return (
    <div>
      {/* Controlled input */}
      <input
        value={search}                              // CONTROLLED!
        onChange={(e) => setSearch(e.target.value)} // Update state
      />

      {/* Contacts list */}
      {contacts.map(contact => (
        <div key={contact.id}>{contact.name}</div>
      ))}
    </div>
  );
}

Step-by-step breakdown of what happened:

1. User types 'a' in the search field
   |
2. onChange calls setSearch('a')
   |
3. THE ENTIRE Contacts COMPONENT RE-RENDERS
   (because state changed)
   |
4. React calls the Contacts() function again
   |
5. NEW JSX is created with new <input value="a" />
   |
6. React compares old and new Virtual DOM
   |
7. React updates the DOM
   |
8. PROBLEM: During DOM update, the browser may
      "recreate" the element or lose focus
      (especially with conditional rendering, lists, etc.)
   |
9. User loses focus and cannot continue typing

Main reasons for focus loss:

1. React reconciliation algorithm: When a parent component re-renders, React may "replace" child elements, even if they're "similar"

2. Key prop issues: If list elements don't have stable keys, React may confuse them

3. Conditional rendering: If there's conditional rendering around the input ({condition && <input />}), the element may be recreated

4. Parent component updates: When a parent component updates (via other state), all child components also update

How Uncontrolled Input Solved the Problem

New Implementation

Key idea: Extract SearchInput into a separate component and make it uncontrolled.

// 1. Separate memoized component
const SearchInput = React.memo(() => {
  const inputRef = useRef(null);
  const timeoutRef = useRef(null);
  const onChangeRef = useRef(null);

  // One-time initialization of event listeners
  useEffect(() => {
    const handleSetCallback = (e) => {
      onChangeRef.current = e.detail.onChange;
    };

    window.addEventListener('setSearchCallback', handleSetCallback);
    return () => window.removeEventListener('setSearchCallback', handleSetCallback);
  }, []);  // Empty array = runs ONCE

  const handleChange = (e) => {
    const value = e.target.value;

    // Debounce logic
    if (timeoutRef.current) clearTimeout(timeoutRef.current);

    timeoutRef.current = setTimeout(() => {
      if (onChangeRef.current) {
        onChangeRef.current(value);  // Call parent handler
      }
    }, 500);
  };

  return (
    <input
      ref={inputRef}
      onChange={handleChange}
      // NO value prop!
    />
  );
});

// 2. Parent component
function Contacts() {
  const [contacts, setContacts] = useState([]);
  const onChangeRef = useRef(null);

  // Create callback for search
  onChangeRef.current = useCallback((value) => {
    // Call API with new search value
    fetchContacts(1, value, filterParams);
  }, [fetchContacts, filterParams]);

  // Pass callback to SearchInput via custom event
  useEffect(() => {
    window.dispatchEvent(new CustomEvent('setSearchCallback', {
      detail: { onChange: onChangeRef.current }
    }));
  }, []);

  return (
    <div>
      <SearchInput />  {/* DOESN'T re-render! */}
      {contacts.map(contact => (...))}
    </div>
  );
}

Why does this work?

1. User types 'a' in the search field
   |
2. DOM updates the input value itself (native browser behavior)
   FOCUS STAYS - this is a native operation
   |
3. onChange is called with event
   |
4. handleChange processes event and starts debounce timer
   setState is NOT called
   SearchInput component DOESN'T re-render
   |
5. After 500ms, debounce callback fires
   |
6. onChangeRef.current('a') is called
   |
7. Parent Contacts component calls fetchContacts('a')
   |
8. Parent component RE-RENDERS
   (because contacts state updates)
   |
9. BUT! SearchInput DOESN'T re-render
   Why? React.memo() prevents it!
   |
10. FOCUS STAYS - SearchInput
       wasn't repainted and DOM element didn't change

The Three "Magic" Ingredients

1. React.memo() - Component Memoization

const SearchInput = React.memo(() => {
  // ...
});

What it does: Prevents re-rendering of a component if its props haven't changed.

In our case: SearchInput receives NO props, so it NEVER re-renders after the initial render!

Parent component updates
      |
React checks: "Did SearchInput props change?"
      |
Answer: "No, props are empty {}"
      |
React SKIPS SearchInput re-render
      |
DOM element remains unchanged
      |
Focus is preserved

2. useRef() - Stable References

const inputRef = useRef(null);        // Reference to DOM element
const timeoutRef = useRef(null);      // Store timer
const onChangeRef = useRef(null);     // Store callback function

What it does: useRef creates an object that DOESN'T change between re-renders.

Key property: Changing .current does NOT trigger a re-render!

// This triggers a re-render:
const [value, setValue] = useState('hello');
setValue('world');  // Re-render

// This does NOT trigger a re-render:
const valueRef = useRef('hello');
valueRef.current = 'world';  // No re-render!

3. Custom Events - Communication Without Props

// Parent component sends callback
window.dispatchEvent(new CustomEvent('setSearchCallback', {
  detail: { onChange: callback }
}));

// Child component receives callback
window.addEventListener('setSearchCallback', (e) => {
  onChangeRef.current = e.detail.onChange;
});

Why not props? If we passed callback through props:

// BAD idea:
<SearchInput onChange={handleSearch} />

// Problem: every re-render of the parent component
// creates a NEW handleSearch function
// React.memo() sees "new" props
// SearchInput re-renders
// Focus is lost

Custom events solve this: Callback is passed "outside" of React, props remain empty, React.memo() always returns false, SearchInput stays unchanged.

Real-World Analogies

Analogy 1: Phone Conversation

Controlled Input (bad): You tell a secretary what to write, they write a word, then ERASE everything, then REWRITE everything again including the new word. Too much work, constant interruptions.

Uncontrolled Input (good): The secretary just writes everything themselves. When needed, you ask "what's written there?" Fast, no interruptions, efficient.

Analogy 2: Ordering Food

Controlled Input: Waiter: "What will you have?" You: "Piz..." Waiter: "Wait! First I must write down 'Piz'..." Goes to write it down, comes back: "Continue!" You: "...za" Waiter: "Wait! Now I'll write 'Pizza'..." And so on for every letter!

Uncontrolled Input: You say everything at once: "Large Margherita pizza". The waiter writes it all themselves without interrupting. When you're done, they verify and go prepare. Natural and fast.

Technical Implementation

Complete SearchInput Component

const SearchInput = React.memo(() => {
  // Reference to the input DOM element
  const inputRef = useRef<HTMLInputElement>(null);

  // Reference to the debounce timer
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  // Reference to callback function from parent
  const onChangeRef = useRef<((value: string) => void) | null>(null);

  // Initialize event listeners (runs once)
  useEffect(() => {
    // Handler to receive callback from parent
    const handleSetCallback = (e: Event) => {
      const customEvent = e as CustomEvent;
      onChangeRef.current = customEvent.detail.onChange;
    };

    // Handler to clear input (when "Clear" is clicked)
    const handleClearInput = () => {
      if (inputRef.current) {
        inputRef.current.value = '';
      }
    };

    // Subscribe to events
    window.addEventListener('setSearchCallback', handleSetCallback);
    window.addEventListener('clearSearchInput', handleClearInput);

    // Cleanup: unsubscribe on unmount
    return () => {
      window.removeEventListener('setSearchCallback', handleSetCallback);
      window.removeEventListener('clearSearchInput', handleClearInput);
    };
  }, []); // Empty dependency array = runs once

  // Handler for text changes in input
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;

    // Debounce: cancel previous timer
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    // Create new timer for 500ms
    timeoutRef.current = setTimeout(() => {
      // After 500ms, call parent callback
      if (onChangeRef.current) {
        onChangeRef.current(value);
      }
    }, 500);
  };

  return (
    <div className="flex-1 max-w-lg">
      <div className="relative rounded-md shadow-sm">
        <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
          <MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
        </div>
        <input
          ref={inputRef}                    // Reference to DOM
          type="text"
          onChange={handleChange}           // Change handler
          placeholder="Search contacts..."
          className="block w-full rounded-md border-0 py-2 pl-10 pr-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
          // NO value prop - this makes it uncontrolled!
        />
      </div>
    </div>
  );
});

Usage in Parent Component

function Contacts() {
  const [contacts, setContacts] = useState<Contact[]>([]);
  const [search, setSearch] = useState('');
  const [page, setPage] = useState(1);
  const [filterParams, setFilterParams] = useState({...});

  // Create stable reference to callback
  const onChangeRef = useRef<((value: string) => void) | null>(null);

  // Function to load contacts
  const fetchContacts = useCallback(async (
    page: number,
    searchQuery: string,
    filters: ContactFilters
  ) => {
    // ... API request
    setContacts(response.data.contacts);
  }, []);

  // Update callback when dependencies change
  onChangeRef.current = useCallback((value: string) => {
    setSearch(value);
    fetchContacts(1, value, filterParams);
  }, [fetchContacts, filterParams]);

  // Pass callback to SearchInput via custom event
  useEffect(() => {
    window.dispatchEvent(new CustomEvent('setSearchCallback', {
      detail: { onChange: onChangeRef.current }
    }));
  }, [onChangeRef.current]);

  return (
    <div className="p-4 sm:p-6 lg:p-8">
      {/* Uncontrolled SearchInput */}
      <SearchInput />

      {/* Contacts list */}
      {contacts.map(contact => (
        <div key={contact.id}>
          {contact.name}
        </div>
      ))}
    </div>
  );
}

Pros and Cons of Each Approach

Controlled Input

Advantages:

- Full control over the value

- Easy real-time validation

- Can format values on the fly

- Single source of truth

Disadvantages:

- Re-renders on every change

- May lose focus

- Slower for large forms

- Performance issues for complex components

When to use: Forms with validation, input masks (phone, credit card), input restrictions (numbers only), small forms

Uncontrolled Input

Advantages:

- Faster - fewer re-renders

- Focus always preserved

- Better performance for large lists

- Natural browser behavior

Disadvantages:

- Less control

- Harder to do real-time validation

- Need ref to access value

- Slightly more complex implementation

When to use: Search with debounce, large forms, inputs in frequently updating lists, performance-critical cases

Conclusion

Why did Uncontrolled Input solve the problem?

1. React.memo() prevents SearchInput component re-renders

2. Absence of value prop makes the input uncontrolled - DOM manages the value itself

3. useRef() stores stable references without triggering re-renders

4. Custom Events pass callbacks without props, preserving memoization

5. Debounce delays API calls, reducing request count

The Result

BEFORE: Type 'a' - Re-render - Focus lost

AFTER: Type 'a' - DOM updates - Focus preserved

Key Lesson

React is about trade-offs:

- Controlled = more control, but more re-renders

- Uncontrolled = less control, but better performance

Choose the right tool for the right job!

Additional Resources

- React Docs: Controlled Components - react.dev/learn/sharing-state-between-components

- React Docs: useRef - react.dev/reference/react/useRef

- React Docs: React.memo - react.dev/reference/react/memo

- React Docs: Preserving and Resetting State - react.dev/learn/preserving-and-resetting-state

About the Author

Ihor (Harry) Chyshkala

Ihor (Harry) Chyshkala

Code Alchemist: Transmuting Ideas into Reality with JS & PHP. DevOps Wizard: Transforming Infrastructure into Cloud Gold | Orchestrating CI/CD Magic | Crafting Automation Elixirs