
Uncontrolled SearchInput: Solving the Focus Loss Problem in React
Audio Narration
Uncontrolled SearchInput: Solving the Focus Loss Problem in React
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 changedWhat 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 STAYSComparison 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 typingMain 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 changeThe 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 preserved2. useRef() - Stable References
const inputRef = useRef(null); // Reference to DOM element
const timeoutRef = useRef(null); // Store timer
const onChangeRef = useRef(null); // Store callback functionWhat 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 lostCustom 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
