Extract the React Search Function to its Own Component - DAV-1000/react-material GitHub Wiki
Created: 8/5/2025 15:19
Updated: 8/5/2025 17:04
Exported: 8/14/2025 12:05
Link: https://chatgpt.com/c/68921285-a8f4-8327-ab7f-686c505f6d9d
My Typescipt Material UI React app has a search box on the home page which is implemented with an inline function. How to extract this functionality to a separate reusable component which will fire an event that can be captured in the hosting page when the search term changes? This is the current function: export function Search() { return ( <FormControl sx={{ width: { xs: '100%', md: '25ch' } }} variant="outlined"> <OutlinedInput size="small" id="search" placeholder="Search…" sx={{ flexGrow: 1 }} startAdornment={ <InputAdornment position="start" sx={{ color: 'text.primary' }}> } inputProps={{ 'aria-label': 'search', }} /> ); }
Thought for 4 seconds
To build a reusable search component with debouncing in TypeScript, React, and Material UI:
- Provide
SearchProps
interface with optional fields for value, onChange, placeholder, size, and debounceMs. - Use controlled/uncontrolled component techniques with
useState
,useRef
, anduseEffect
for the debounce logic. - Alternate with
TextField
orOutlinedInput
for search input. - Show example usage on host page: `
Done
Nice — you’ve already got the UI bits in place. Below I’ll show a clean, reusable, TypeScript Search
component (Material-UI) that:
- accepts props (
value
,onChange
,placeholder
,debounceMs
,sx
, etc.), - supports controlled and uncontrolled usage,
- optionally debounces the
onChange
callback, - keeps the same appearance you had (uses
OutlinedInput
+InputAdornment
).
After the component I’ll show how to use it from your page and how to capture the search term changes.
import React, { useEffect, useState, useRef } from 'react';
import { FormControl, OutlinedInput, InputAdornment, SxProps } from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
/** Controlled value. If not provided the component is uncontrolled. */
value?: string;
/** Called when the search term changes (debounced if debounceMs > 0). */
onChange?: (value: string) => void;
/** Placeholder text. */
placeholder?: string;
/** debounce time in ms for calling onChange. Set 0 for immediate. Default 300. */
debounceMs?: number;
/** size prop forwarded to OutlinedInput */
size?: 'small' | 'medium';
/** MUI sx for the FormControl wrapper */
sx?: SxProps;
/** id for the input */
id?: string;
}
export function Search({
value,
onChange,
placeholder = 'Search…',
debounceMs = 300,
size = 'small',
sx,
id = 'search',
}: SearchProps) {
// Internal state used when component is uncontrolled OR to handle immediate typing
const [internal, setInternal] = useState<string>(value ?? '');
const mountedRef = useRef(true);
const timeoutRef = useRef<number | null>(null);
// Keep internal state in sync when controlled value changes from parent
useEffect(() => {
if (typeof value === 'string') {
setInternal(value);
}
}, [value]);
useEffect(() => {
return () => {
mountedRef.current = false;
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
const callOnChange = (next: string) => {
if (!onChange) return;
if (!debounceMs) {
onChange(next);
return;
}
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
// schedule debounced callback
timeoutRef.current = window.setTimeout(() => {
// guard against calling after unmount
if (!mountedRef.current) return;
onChange(next);
}, debounceMs);
};
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const next = e.target.value;
// Update local internal state always so input stays responsive
setInternal(next);
// If component is controlled (value prop provided) we still call onChange
callOnChange(next);
};
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<OutlinedInput
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
// controlled when value prop is provided, otherwise use internal state
value={typeof value === 'string' ? value : internal}
onChange={handleInput}
/>
</FormControl>
);
}
Controlled usage (page keeps the single source of truth and receives debounced updates):
import React, { useState } from 'react';
import { Search } from './Search';
export default function HomePage() {
const [query, setQuery] = useState('');
// This will be called after the debounce time (default 300ms)
const handleSearchChange = (next: string) => {
setQuery(next);
// trigger search / API call / filter logic here
console.log('Search term changed:', next);
};
return (
<div>
<Search value={query} onChange={handleSearchChange} placeholder="Search items..." />
<div>Searching for: {query}</div>
{/* ...rest of page */}
</div>
);
}
Uncontrolled usage (component owns the internal state; you still get updates via onChange
):
<Search onChange={(s) => console.log('uncontrolled search changed:', s)} />
-
Debounce:
debounceMs
defaults to300
. Set it to0
to callonChange
immediately on every keystroke. -
Controlled vs Uncontrolled: If you pass
value
, the component behaves as controlled (the parent must updatevalue
to change the input). If you don't passvalue
, it's uncontrolled and maintains its own local state but still callsonChange
. -
Accessibility:
inputProps={{ 'aria-label': 'search' }}
is preserved. You may add a visiblelabel
oraria-label
customization via props. -
Cancel pending debounce on unmount: component clears the timeout on unmount to avoid calling
onChange
after unmount. -
Extra features you might add later: an
onSubmit
prop (submit on Enter), a clear (x) button, or keyboard shortcuts.
If you want, I can also:
- add an
onSubmit
handler that fires when the user presses Enter, - add a "clear" icon button to reset the search,
- show how to hook this into a server-side search (e.g., cancel in-flight requests using
AbortController
), or - convert the debounce to use
useDebouncedCallback
fromuse-debounce
if you prefer a library.
Which of those would you like next?
In this line of Typescript, what will the value of matchesSearch be when searchTerm is null
If you mean something like this:
const matchesSearch = someString.includes(searchTerm);
and searchTerm
is null
, then TypeScript will actually not compile unless searchTerm
is typed as any
(or you explicitly cast it), because .includes
expects a string
or RegExp
— not null
.
But in JavaScript at runtime (if you somehow get null
past the type system), what happens is:
"abc".includes(null); // true or false?
-
String.prototype.includes
internally coerces its argument to a string. -
String(null)
becomes"null"
. - So
"abc".includes(null)
→"abc".includes("null")
→false
. - If the string contained
"null"
, it would returntrue
.
Example:
"this is null text".includes(null); // true
"hello".includes(null); // false
So:
- If you’re in strict TypeScript → compile-time error unless you cast.
-
If you bypass types → it behaves as if searching for the literal string
"null"
.
If you show me your exact line of code, I can explain the exact compile-time and runtime behavior. Are you using .includes()
or something else for matchesSearch
?
I forgot to paste the line of typescript. Here it is: const matchesSearch = !searchTerm || data.title.toLowerCase().includes(searchTerm.toLowerCase()) || data.description.toLowerCase().includes(searchTerm.toLowerCase());
Got it — now we can break down exactly what happens when searchTerm
is null
.
Here’s the line again:
const matchesSearch =
!searchTerm ||
data.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
data.description.toLowerCase().includes(searchTerm.toLowerCase());
-
!searchTerm
check- In JavaScript,
null
is falsy. -
!null
→true
. - Since this is the first part of the
||
chain, the moment it evaluates totrue
, JavaScript short-circuits — it does not evaluate the rest.
- In JavaScript,
-
Short-circuit effect
- That means
searchTerm.toLowerCase()
is never called whensearchTerm
isnull
, so you avoid a runtime error. - The whole expression becomes
true
.
- That means
If searchTerm
is null
:
matchesSearch === true
💡 Why this pattern works:
This is a common “match everything if no search term is provided” idiom. !searchTerm
will be true
for null
, undefined
, ''
(empty string), 0
, and false
.
If you only want to treat ''
(empty string) or null
as “no search term” but not 0
or false
, you’d need a stricter check, e.g.:
searchTerm == null || searchTerm.trim() === ''
If you want, I can rewrite this in a safer TypeScript-friendly way so that searchTerm
can be string | null
without compiler complaints.
In this codeblock, mountedRef.current seems to be always false and the onChange event is never fired: timeoutRef.current = window.setTimeout(() => { // guard against calling after unmount if (!mountedRef.current) return; onChange(next); }, debounceMs);
Ah, I see what’s happening — it’s a subtle lifecycle timing issue in your debounce code.
In the version I gave earlier, I had:
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
That means:
-
mountedRef.current
starts astrue
. - On unmount, it’s set to
false
. - Inside the
setTimeout
callback, we checkif (!mountedRef.current) return;
.
If you accidentally initialized mountedRef
with false
:
const mountedRef = useRef(false);
then it will never get set to true
unless you explicitly do so on mount.
Or, if you have:
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
but you never set it to true
on mount, it’ll stay at its initial value (false
).
To ensure it’s set correctly on mount and cleared on unmount:
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true; // now it's true after mount
return () => {
mountedRef.current = false; // set to false on unmount
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
In your debounce case, you only need to guard against calling onChange
after unmount. Clearing the timeout in the cleanup already achieves that:
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
If you do that, the callback will never run after unmount, so mountedRef
is redundant.
Recommendation:
For this onChange
debounce, I’d drop mountedRef
entirely and just cancel the timer in the cleanup — less state, less chance of confusion.
Do you want me to rewrite your Search
component’s debounce logic so it works without mountedRef
and still avoids any memory leaks? That would fix this permanently.
Yes
Great — here’s a cleaned-up TypeScript Search
component that uses a debounced onChange
without mountedRef
. Instead it cancels the pending timer in a useEffect
cleanup so the callback never runs after unmount. That removes the weird lifecycle bug and is simpler and safer.
import React, { useEffect, useRef, useState } from 'react';
import { FormControl, OutlinedInput, InputAdornment, SxProps } from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
debounceMs?: number; // 0 => immediate
size?: 'small' | 'medium';
sx?: SxProps;
id?: string;
}
export function Search({
value,
onChange,
placeholder = 'Search…',
debounceMs = 300,
size = 'small',
sx,
id = 'search',
}: SearchProps) {
// internal state used when uncontrolled
const [internal, setInternal] = useState<string>(value ?? '');
const timeoutRef = useRef<number | null>(null);
// keep internal in sync if parent controls the value
useEffect(() => {
if (typeof value === 'string') {
setInternal(value);
}
}, [value]);
// clear timeout on unmount (and when debounceMs changes)
useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [/* no deps needed for unmount cleanup only */]);
const scheduleOnChange = (next: string) => {
if (!onChange) return;
// immediate call
if (!debounceMs) {
onChange(next);
return;
}
// clear any previous pending timer
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
// schedule the debounced callback
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null;
onChange(next);
}, debounceMs);
};
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const next = e.target.value;
setInternal(next);
scheduleOnChange(next);
};
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<OutlinedInput
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
value={typeof value === 'string' ? value : internal}
onChange={handleInput}
/>
</FormControl>
);
}
-
No
mountedRef
— we don't track mount state ourselves. -
Timeout cleanup — the
useEffect
cleanup clears any scheduledsetTimeout
, so the callback never runs after unmount. That prevents callingonChange
on an unmounted component and avoids the race that mademountedRef.current
appearfalse
. -
Simple logic — immediate calls when
debounceMs
is0
, otherwise a single debounced timer is used and always cleared before scheduling a new one.
Controlled (recommended if parent needs the canonical value):
import React, { useState } from 'react';
import { Search } from './Search';
export default function Home() {
const [query, setQuery] = useState('');
const handleSearch = (term: string) => {
setQuery(term);
// run API call / filter logic here
console.log('Debounced search term:', term);
};
return (
<div>
<Search value={query} onChange={handleSearch} debounceMs={300} />
<div>Searching for: {query}</div>
</div>
);
}
Uncontrolled (component manages input, but parent still receives onChange events):
<Search onChange={(s) => console.log('uncontrolled search:', s)} debounceMs={250} />
If you want, I can:
- add an Enter-to-submit
onSubmit
prop, - add a clear (×) button that resets the input and notifies the parent,
- or switch the debounce implementation to
use-debounce
/lodash.debounce
(library-based).
Which (if any) would you like next?
That's now running slowly. Remove all the debounce code, add a button to the right of the input box and fire onChange when it is clicked
Thought for a couple of seconds
I need to simplify
Done
Nice — here’s a minimal, debounce-free Search
component that adds a Search button to the right of the input. Clicking the button will call the onChange
callback with the current input value. It also keeps the controlled/uncontrolled behavior you had before.
import React, { useEffect, useRef, useState } from 'react';
import { Box, Button, FormControl, OutlinedInput, InputAdornment, SxProps } from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
/** Controlled value. If not provided the component is uncontrolled. */
value?: string;
/** Called when the user clicks the search button (or presses Enter). */
onChange?: (value: string) => void;
placeholder?: string;
size?: 'small' | 'medium';
sx?: SxProps;
id?: string;
/* Button label (optional) */
buttonLabel?: string;
}
export function Search({
value,
onChange,
placeholder = 'Search…',
size = 'small',
sx,
id = 'search',
buttonLabel = 'Search',
}: SearchProps) {
const [internal, setInternal] = useState<string>(value ?? '');
const inputRef = useRef<HTMLInputElement | null>(null);
// keep internal in sync if parent controls the value
useEffect(() => {
if (typeof value === 'string') {
setInternal(value);
}
}, [value]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInternal(e.target.value);
};
const triggerChange = () => {
if (onChange) {
onChange(value !== undefined ? value : internal);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// prevent form submission if inside a form; still notify parent
e.preventDefault();
triggerChange();
}
};
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<OutlinedInput
inputRef={inputRef}
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
value={typeof value === 'string' ? value : internal}
onChange={handleInput}
onKeyDown={handleKeyDown}
/>
<Button
variant="contained"
size={size === 'small' ? 'small' : 'medium'}
onClick={triggerChange}
sx={{ ml: 1 }}
aria-label="perform search"
>
{buttonLabel}
</Button>
</Box>
</FormControl>
);
}
Controlled usage (parent keeps canonical value):
import React, { useState } from 'react';
import { Search } from './Search';
export default function Home() {
const [query, setQuery] = useState('');
const handleSearch = (term: string) => {
setQuery(term);
// run API call / filter logic now
console.log('Search clicked with term:', term);
};
return (
<div>
<Search value={query} onChange={handleSearch} buttonLabel="Find" />
</div>
);
}
Uncontrolled usage (component manages input; parent still receives the search value on click):
<Search onChange={(val) => console.log('Search clicked:', val)} />
-
No debounce: the
onChange
is only fired when the button is clicked (or Enter is pressed). This avoids the slow behaviour you saw with debouncing. -
Enter support: pressing Enter also triggers
onChange
(prevents default form submit). -
Controlled vs uncontrolled: If you pass
value
, the component is controlled; otherwise it’s uncontrolled but still reports the current text on button click.
If you want the button to be an IconButton
inside the input (smaller, inline), or want the button to be disabled when the input is empty, I can show those variants. Which would you prefer?
value should be string | null
Got it — we’ll explicitly type value
as string | null
in the props, and adjust the internal logic so that:
-
null
means “no value” (treated as empty string in the input box) - The
onChange
callback will always receivestring | null
- Controlled/uncontrolled still works
Here’s the updated no-debounce Search
component with a Search button:
import React, { useEffect, useRef, useState } from 'react';
import { Box, Button, FormControl, OutlinedInput, InputAdornment, SxProps } from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
/** Controlled value. If not provided, the component is uncontrolled. */
value?: string | null;
/** Called when the user clicks the search button (or presses Enter). */
onChange?: (value: string | null) => void;
placeholder?: string;
size?: 'small' | 'medium';
sx?: SxProps;
id?: string;
/** Optional button label */
buttonLabel?: string;
}
export function Search({
value,
onChange,
placeholder = 'Search…',
size = 'small',
sx,
id = 'search',
buttonLabel = 'Search',
}: SearchProps) {
const [internal, setInternal] = useState<string>(value ?? '');
const inputRef = useRef<HTMLInputElement | null>(null);
// keep internal in sync if parent controls the value
useEffect(() => {
if (value !== undefined) {
setInternal(value ?? ''); // null → ''
}
}, [value]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInternal(e.target.value);
};
const triggerChange = () => {
if (onChange) {
const currentValue = value !== undefined ? value : internal;
const normalized = currentValue === '' ? null : currentValue;
onChange(normalized);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
triggerChange();
}
};
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<OutlinedInput
inputRef={inputRef}
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
value={value !== undefined ? value ?? '' : internal}
onChange={handleInput}
onKeyDown={handleKeyDown}
/>
<Button
variant="contained"
size={size === 'small' ? 'small' : 'medium'}
onClick={triggerChange}
sx={{ ml: 1 }}
aria-label="perform search"
>
{buttonLabel}
</Button>
</Box>
</FormControl>
);
}
-
Prop typing
value?: string | null; onChange?: (value: string | null) => void;
- If
value
isnull
, input shows''
. - If
value
isundefined
, component is uncontrolled.
- If
-
Normalization on trigger
const normalized = currentValue === '' ? null : currentValue; onChange(normalized);
- Empty string becomes
null
before sending to parent.
- Empty string becomes
-
State sync
setInternal(value ?? '');
- Keeps internal state consistent if parent updates to
null
orstring
.
- Keeps internal state consistent if parent updates to
import React, { useState } from 'react';
import { Search } from './Search';
export default function Home() {
const [query, setQuery] = useState<string | null>(null);
const handleSearch = (term: string | null) => {
setQuery(term);
console.log('Search clicked with:', term);
};
return (
<div>
<Search value={query} onChange={handleSearch} buttonLabel="Find" />
<div>Searching for: {query ?? '(none)'}</div>
</div>
);
}
Do you want me to also make the button disabled when the input is empty so it only fires when there’s a search term? That works well with string | null
semantics.
Does the material UI not have a search icon button similar to the rss feed button here:
Yep — Material UI absolutely has IconButton
, and you can use it with SearchRoundedIcon
(or any other search icon) in exactly the same way you used RssFeedRoundedIcon
.
For example:
import IconButton from '@mui/material/IconButton';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
<IconButton size="small" aria-label="Search">
<SearchRoundedIcon />
</IconButton>
If you want that as the search trigger in your Search
component (instead of a full Button
with text), you can change the right-side button to an IconButton
. Here’s the relevant part of the component updated:
<IconButton
size={size === 'small' ? 'small' : 'medium'}
onClick={triggerChange}
aria-label="Search"
sx={{ ml: 1 }}
>
<SearchRoundedIcon />
</IconButton>
This will give you exactly the same look-and-feel as your RSS feed example — just with the search icon.
Do you want me to rewrite the full Search
component so it uses IconButton
with a search icon on the right, instead of the text button? That would match the RSS feed example perfectly.
Yes
Nice — below is a complete, TypeScript Search
component updated to use an IconButton
(with SearchRoundedIcon
) on the right side as the trigger. It preserves the value?: string | null
typing, supports controlled/uncontrolled usage, fires onChange(value: string | null)
when the icon button is clicked or the user presses Enter, and treats ''
as null
when sending to the host.
// Search.tsx
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
FormControl,
OutlinedInput,
InputAdornment,
IconButton,
SxProps,
} from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
/** Controlled value. If omitted, component is uncontrolled. null means "no value". */
value?: string | null;
/** Called when the user clicks the search icon or presses Enter. Receives string | null. */
onChange?: (value: string | null) => void;
placeholder?: string;
size?: 'small' | 'medium';
sx?: SxProps;
id?: string;
/** aria-label for the icon button (defaults to "perform search") */
iconAriaLabel?: string;
/** optionally disable the icon button when input is empty (default false) */
disableWhenEmpty?: boolean;
}
export function Search({
value,
onChange,
placeholder = 'Search…',
size = 'small',
sx,
id = 'search',
iconAriaLabel = 'perform search',
disableWhenEmpty = false,
}: SearchProps) {
// Internal state used when uncontrolled
const [internal, setInternal] = useState<string>(value ?? '');
const inputRef = useRef<HTMLInputElement | null>(null);
// Keep internal state in sync when controlled
useEffect(() => {
if (value !== undefined) {
setInternal(value ?? '');
}
}, [value]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInternal(e.target.value);
};
const triggerChange = () => {
if (!onChange) return;
const currentValue = value !== undefined ? value : internal;
const normalized = currentValue === '' ? null : currentValue;
onChange(normalized);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
triggerChange();
}
};
const currentDisplayed = value !== undefined ? value ?? '' : internal;
const isEmpty = currentDisplayed.trim() === '';
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<OutlinedInput
inputRef={inputRef}
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
value={currentDisplayed}
onChange={handleInput}
onKeyDown={handleKeyDown}
/>
<IconButton
size={size === 'small' ? 'small' : 'medium'}
onClick={triggerChange}
aria-label={iconAriaLabel}
sx={{ ml: 1 }}
disabled={disableWhenEmpty && isEmpty}
>
<SearchRoundedIcon />
</IconButton>
</Box>
</FormControl>
);
}
export default Search;
Controlled (recommended when parent tracks the canonical value):
import React, { useState } from 'react';
import Search from './Search';
export default function Home() {
const [query, setQuery] = useState<string | null>(null);
const handleSearch = (term: string | null) => {
setQuery(term);
// run API call / filter logic now
console.log('Search triggered with:', term);
};
return (
<div>
<Search value={query} onChange={handleSearch} disableWhenEmpty />
<div>Searching for: {query ?? '(none)'}</div>
</div>
);
}
Uncontrolled (component manages input; parent just receives search when triggered):
<Search onChange={(val) => console.log('Search clicked:', val)} />
-
value?: string | null
—undefined
= uncontrolled,null
= explicit "no value". -
onChange
always receivesstring | null
. Empty string becomesnull
. -
disableWhenEmpty
will disable the icon button when the input is empty; useful to prevent empty searches. - The icon button visually matches your RSS
IconButton
example and is accessible viaaria-label
.
Want the icon button inside the input (right adornment) instead of beside it? I can show that variant too — it’s a slightly different layout but looks more compact.
I can put the cursor in the search input box but when I type, no text is entered
Ah — that happens when the component is controlled (i.e., value
prop is defined) but the parent isn’t updating it on onChange
.
In your current setup:
- The
OutlinedInput
’svalue
comes from:
value={currentDisplayed}
- If you pass
value={query}
from the parent but don’t updatequery
when typing, the input shows the old value forever (and appears “frozen”).
When you pass value
to a Material UI input:
-
It becomes controlled → React ignores user keystrokes unless
value
changes in the parent state. -
onChange
in the child is only called when you click the search icon (per our new code), so typing does not update the parent. - Since the parent’s
value
never changes during typing, the UI stays stuck.
If you only want to notify the parent when the search icon is clicked (not on every keystroke), don’t pass the value
prop from the parent.
This will make it “uncontrolled” and the internal state will handle typing:
// Home.tsx
<Search onChange={(term) => console.log(term)} />
If you must keep the parent as the single source of truth, pass both value
and an onChange
handler that updates it while typing.
Example:
const [query, setQuery] = useState<string | null>(null);
return (
<Search
value={query}
onChange={(val) => {
setQuery(val); // updates parent state so typing works
// Optional: trigger search logic here if you want it live
}}
/>
);
If your intent is “only fire search when the icon is clicked, but allow typing in between”, then Option 1 is the simplest — just don’t pass value
at all.
Do you want me to tweak the Search
component so that even in controlled mode it only calls onChange
when the icon is clicked, but still allows typing without freezing? That’s possible by adding a separate onInputChange
prop for keystrokes.
Just implement Option 1
Thought for a couple of seconds
The user wants to implement Option 1, which suggests updating the Search component to use it without passing the value prop from the parent. I’ll provide an updated usage example for the parent/Home component, showing how to handle `on
Done
Perfect — Option 1 means don’t pass value
from the parent. Let the Search
component manage the input text internally and only notify the parent when the icon button is clicked (or Enter is pressed). That will make typing work normally.
-
Remove
value={...}
from the<Search />
usage in the parent. - Keep
onChange
if you want to receive the query when the user triggers search.
// Home.tsx
import React, { useState } from 'react';
import Search from './Search'; // the Search component you already have
export default function Home() {
// Optional: store the last submitted search
const [submitted, setSubmitted] = useState<string | null>(null);
const handleSearch = (term: string | null) => {
setSubmitted(term);
console.log('Search submitted:', term);
// call API / filter logic here
};
return (
<div>
{/* NOTE: no `value` prop passed — component is uncontrolled */}
<Search onChange={handleSearch} disableWhenEmpty />
<div>Last submitted: {submitted ?? '(none)'}</div>
</div>
);
}
- When you pass
value
to theSearch
component it becomes controlled. The parent must update thatvalue
on every keystroke for the input to change. - By not passing
value
, theSearch
component uses its internal state (internal
) for the input, so typing works normally. The parent only receives the value when the user clicks the icon or presses Enter.
If you want the Search
component to clear itself after a submit and then notify the parent, either:
- Modify the
Search
component to clear internal state insidetriggerChange()
after callingonChange
, or - Expose a
ref
API fromSearch
so the parent can callreset()
.
If you want, I can add the auto-clear behavior to triggerChange()
now — shall I?
Give me the full code for the option 1 search component
Thought for a few seconds
The user seems clear about Option 1—an uncontrolled search component. I’ll strip out the value
prop entirely to avoid confusion, even though the original request mentioned string | null
. Since this is about keeping things uncontrolled, the value won’t be explicitly passed, but internal state will manage it. I’ll ensure the onChange
is triggered by clicking the IconButton or pressing Enter, and remember to include the disableWhenEmpty
feature
Done
Nice — below is a complete, uncontrolled Search
component (Option 1). It manages its own input state, shows an OutlinedInput
with a left search icon and a right IconButton
(search icon). Typing works normally because the parent does not pass a value
prop; the parent is only notified when the icon is clicked or Enter is pressed via onChange(value: string | null)
.
Save this as Search.tsx
(or whatever you prefer):
// Search.tsx
import React, { useRef, useState } from 'react';
import {
Box,
FormControl,
OutlinedInput,
InputAdornment,
IconButton,
SxProps,
} from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
/** Called when the user clicks the search icon or presses Enter. Receives string | null. */
onChange?: (value: string | null) => void;
placeholder?: string;
size?: 'small' | 'medium';
sx?: SxProps;
id?: string;
/** aria-label for the icon button (defaults to "perform search") */
iconAriaLabel?: string;
/** optionally disable the icon button when input is empty (default false) */
disableWhenEmpty?: boolean;
/** whether to clear the input after submit (default false) */
clearOnSubmit?: boolean;
}
export default function Search({
onChange,
placeholder = 'Search…',
size = 'small',
sx,
id = 'search',
iconAriaLabel = 'perform search',
disableWhenEmpty = false,
clearOnSubmit = false,
}: SearchProps) {
const [internal, setInternal] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setInternal(e.target.value);
};
const triggerChange = () => {
if (!onChange) {
if (clearOnSubmit) setInternal('');
return;
}
const normalized = internal.trim() === '' ? null : internal;
onChange(normalized);
if (clearOnSubmit) setInternal('');
// optionally refocus input after submit
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
triggerChange();
}
};
const isEmpty = internal.trim() === '';
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<OutlinedInput
inputRef={inputRef}
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
value={internal}
onChange={handleInput}
onKeyDown={handleKeyDown}
/>
<IconButton
size={size === 'small' ? 'small' : 'medium'}
onClick={triggerChange}
aria-label={iconAriaLabel}
sx={{ ml: 1 }}
disabled={disableWhenEmpty && isEmpty}
>
<SearchRoundedIcon />
</IconButton>
</Box>
</FormControl>
);
}
// Home.tsx
import React, { useState } from 'react';
import Search from './Search';
export default function Home() {
const [lastSubmitted, setLastSubmitted] = useState<string | null>(null);
const handleSearch = (term: string | null) => {
setLastSubmitted(term);
// run API / filter / navigation here
console.log('Search submitted:', term);
};
return (
<div>
{/* IMPORTANT: do NOT pass `value` here — keep the component uncontrolled */}
<Search onChange={handleSearch} disableWhenEmpty clearOnSubmit={false} />
<div>Last submitted: {lastSubmitted ?? '(none)'}</div>
</div>
);
}
If you want any of these small tweaks, say the word and I’ll update the component:
- put the search
IconButton
inside the input as a right adornment (compact look), - auto-disable the icon unless the input changed since last submit,
- expose a
ref
API to let the parent reset or focus the input.
How can I make onchange fire automatically when the input is cleared
Right now in the uncontrolled version, onChange
only fires when the search icon is clicked or Enter is pressed.
If you want it to also fire immediately when the input is cleared (set to ''
), you can detect that in handleInput
and call onChange(null)
right there.
Here’s the updated bit inside Search
:
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInternal(newValue);
// If the input has been cleared, fire onChange immediately
if (newValue.trim() === '') {
onChange?.(null);
}
};
import React, { useRef, useState } from 'react';
import {
Box,
FormControl,
OutlinedInput,
InputAdornment,
IconButton,
SxProps,
} from '@mui/material';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
export interface SearchProps {
onChange?: (value: string | null) => void;
placeholder?: string;
size?: 'small' | 'medium';
sx?: SxProps;
id?: string;
iconAriaLabel?: string;
disableWhenEmpty?: boolean;
clearOnSubmit?: boolean;
}
export default function Search({
onChange,
placeholder = 'Search…',
size = 'small',
sx,
id = 'search',
iconAriaLabel = 'perform search',
disableWhenEmpty = false,
clearOnSubmit = false,
}: SearchProps) {
const [internal, setInternal] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInternal(newValue);
// Auto-fire onChange when cleared
if (newValue.trim() === '') {
onChange?.(null);
}
};
const triggerChange = () => {
const normalized = internal.trim() === '' ? null : internal;
onChange?.(normalized);
if (clearOnSubmit) setInternal('');
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
triggerChange();
}
};
const isEmpty = internal.trim() === '';
return (
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as any) }} variant="outlined">
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<OutlinedInput
inputRef={inputRef}
size={size}
id={id}
placeholder={placeholder}
sx={{ flexGrow: 1 }}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
inputProps={{ 'aria-label': 'search' }}
value={internal}
onChange={handleInput}
onKeyDown={handleKeyDown}
/>
<IconButton
size={size === 'small' ? 'small' : 'medium'}
onClick={triggerChange}
aria-label={iconAriaLabel}
sx={{ ml: 1 }}
disabled={disableWhenEmpty && isEmpty}
>
<SearchRoundedIcon />
</IconButton>
</Box>
</FormControl>
);
}
With this change:
- Click icon →
onChange(...)
fires with current text. - Press Enter →
onChange(...)
fires with current text. -
Clear input manually (e.g., backspace until empty) →
onChange(null)
fires immediately without needing to press the icon.
Do you want me to make it also fire onChange('something')
live as you type (not just when cleared)? That would turn it into a live-search mode.
The site has a dark mode option which uses @mui/system useColorScheme. When dark mode is selected, the search box is almost invisible and the placeholder text a very faint grey. What should an input box look like in dark mode
In dark mode, you generally want an input box to remain clearly visible and legible while still blending with the darker UI palette.
For a search box specifically, here’s what a good dark mode style should achieve:
- Contrast — The input’s background should contrast enough with the page background so users can immediately see it.
- Readable text — The text and placeholder should have good contrast (ideally WCAG AA or AAA).
-
Consistent theming — Colors should come from the Material UI theme (
theme.palette
) so they match the rest of your dark mode styles. - Focus indication — A clear visual cue (e.g., border color, glow) when the input is focused.
- No “pure black” text — In dark mode, black text becomes unreadable against dark backgrounds.
Material UI’s design tokens for dark mode:
-
Background:
theme.palette.background.paper
or a slightly lighter tone for input boxes -
Text (normal):
theme.palette.text.primary
-
Text (placeholder):
theme.palette.text.secondary
-
Border/Outline:
theme.palette.divider
or an accent color on focus
In dark mode, MUI typically uses something like:
- Background: ~
#121212
(page) /#1E1E1E
(paper) - Input background: ~
rgba(255, 255, 255, 0.09)
ortheme.palette.grey[900]
- Placeholder text:
rgba(255, 255, 255, 0.7)
<OutlinedInput
placeholder="Search…"
sx={(theme) => ({
// Adjust background and text colors for dark mode
backgroundColor:
theme.palette.mode === 'dark'
? theme.palette.grey[900]
: theme.palette.background.paper,
color: theme.palette.text.primary,
'& .MuiOutlinedInput-notchedOutline': {
borderColor:
theme.palette.mode === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[400],
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor:
theme.palette.mode === 'dark'
? theme.palette.grey[500]
: theme.palette.grey[600],
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.main,
},
'& input::placeholder': {
color: theme.palette.text.secondary,
opacity: 1, // Ensures it's not overly faint
},
})}
startAdornment={
<InputAdornment position="start" sx={{ color: 'text.primary' }}>
<SearchRoundedIcon fontSize="small" />
</InputAdornment>
}
/>
Mode | Background | Text Color | Placeholder Color | Border Color (default) |
---|---|---|---|---|
Light | White / Paper |
text.primary (black-ish) |
text.secondary (grey-ish) |
Light grey |
Dark | Grey 900 (~#121212) |
text.primary (off-white) |
text.secondary (light grey) |
Grey 700 |
💡 If you use useColorScheme()
from @mui/system
, you can pass mode
directly into your sx
styles (as above) so the search input automatically adapts to theme changes.
If you’d like, I can apply this dark-mode styling directly to your uncontrolled Search component so it works seamlessly with useColorScheme
.
That would make your search box always readable in dark mode without extra work.
Do you want me to do that?
I am geting an error: Unexpected any. Specify a different type from line: <FormControl sx={{ width: { xs: "100%", md: "25ch" }, ...(sx as any) }}
That’s because as any
is triggering ESLint’s @typescript-eslint/no-explicit-any
rule.
You don’t actually need to cast to any
here — Material UI already provides a SxProps
type that works for this.
Your prop sx?: SxProps
is correct, but SxProps
is a union type, not directly spreadable without type narrowing.
When you wrote:
...(sx as any)
you bypassed the type check — hence the any
.
Tell TypeScript that sx
is either an object or undefined
before spreading. For example:
import { SxProps, Theme } from '@mui/material/styles';
// In your props
sx?: SxProps<Theme>;
// In the component
<FormControl
sx={{
width: { xs: '100%', md: '25ch' },
...(sx && typeof sx === 'object' ? sx : {}),
}}
variant="outlined"
>
Here:
-
typeof sx === 'object' ? sx : {}
ensures we only spread whensx
is a plain object. - No
any
is involved — so ESLint is happy. -
SxProps<Theme>
is the recommended typing for Material UI’ssx
.
If you know your sx
will always be an object (and not a function), you could also do:
<FormControl sx={{ width: { xs: '100%', md: '25ch' }, ...(sx as Record<string, unknown> ?? {}) }}>
but the first approach is safer for all SxProps
cases.
Do you want me to update your uncontrolled Search component with this sx
fix and the dark mode styling we discussed so it’s fully ready?
Powered by ChatGPT Exporter