Sonamuμ Search Input μ»΄ν¬λνΈλ‘ μ€μκ° κ²μκ³Ό μλμμ± κΈ°λ₯μ ꡬννλ λ°©λ²μ μμλ΄
λλ€.
μ€μκ° κ²μ
νμ΄ννλ©΄μ κ²μ μ¦κ°μ μΈ κ²°κ³Ό
μλμμ±
μΆμ² κ²μμ΄ λΉ λ₯Έ μ ν
λλ°μ΄μ€
API νΈμΆ μ΅μ ν λΆνμν μμ² μ κ±°
νμ΄λΌμ΄ν
κ²μμ΄ κ°μ‘° κ°λ
μ± ν₯μ
λ¬Έμ : κΈ°λ³Έ κ²μμ νκ³
κΈ°λ³Έ HTML <input type="search">λ κ²μ λ²νΌμ λλ¬μΌλ§ κ²μμ΄ μ€νλ©λλ€.
// β μ ν΅μ μΈ κ²μ - λΆνΈν¨
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
const { results } = await searchService.search({ query });
setResults(results);
}
return (
<form onSubmit={handleSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
);
}
λ¬Έμ μ :
- λ²νΌ ν΄λ¦ νμ: μν°ν€λ λ²νΌμ λλ¬μΌλ§ κ²μ
- νΌλλ°± μ§μ°: κ²°κ³Όλ₯Ό λ³΄λ €λ©΄ κ²μ μ€ν νμ
- μλμμ± μμ: μ¬μ©μκ° λͺ¨λ κ²μ νμ΄νν΄μΌ ν¨
- μ€ν μμ μ΄λ €μ: κ²°κ³Όλ₯Ό 보기 μ κΉμ§ μ€ν λ°κ²¬ λΆκ°
Sonamuμ Search Inputμ νμ΄ννλ©΄μ μ€μκ°μΌλ‘ κ²μνκ³ μλμμ±μ μ 곡ν©λλ€.
// β
μ€μκ° κ²μ - νΈλ¦¬ν¨
<SearchInput
onSearch={(query) => {
// νμ΄ννλ©΄μ μλμΌλ‘ νΈμΆλ¨
}}
placeholder="Search..."
showSuggestions
/>
μ₯μ :
- β¨ μ¦κ°μ μΈ νΌλλ°±: νμ΄ννλ©΄μ κ²°κ³Ό νμΈ
- β¨ μλμμ±: μΆμ² κ²μμ΄λ‘ λΉ λ₯Έ μ
λ ₯
- β¨ λλ°μ΄μ€: λΆνμν API νΈμΆ μ΅μν
- β¨ ν€λ³΄λ λ€λΉκ²μ΄μ
: λ°©ν₯ν€λ‘ μ μ νλͺ© μ ν
κΈ°λ³Έ μ¬μ©λ²
λ¨μ κ²μ
κ°μ₯ κΈ°λ³Έμ μΈ κ²μ κΈ°λ₯μ
λλ€.
import { SearchInput } from "@/components/common/SearchInput";
import { useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search posts..."
/>
{query && (
<div>Searching for: {query}</div>
)}
</div>
);
}
μλμμ± ν¬ν¨
μ μ νλͺ©μ νμν©λλ€.
function SearchWithSuggestions() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
// κ²μμ΄ λ³κ²½ μ μ μ λ‘λ
useEffect(() => {
if (query) {
searchService.getSuggestions({ query }).then(({ suggestions }) => {
setSuggestions(suggestions);
});
} else {
setSuggestions([]);
}
}, [query]);
return (
<SearchInput
value={query}
onChange={setQuery}
suggestions={suggestions}
onSelectSuggestion={(suggestion) => {
setQuery(suggestion);
// μ νν μ μμΌλ‘ κ²μ μ€ν
}}
placeholder="Type to search..."
/>
);
}
Serviceμ ν΅ν©
Sonamu Serviceλ₯Ό μ¬μ©νμ¬ κ²μν©λλ€.
import { searchService } from "@/services/search.service";
import { useDebounce } from "@/hooks/useDebounce";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 300);
// λλ°μ΄μ€λ μΏΌλ¦¬λ‘ κ²μ
useEffect(() => {
if (debouncedQuery) {
searchService.search({ query: debouncedQuery }).then(({ results }) => {
setResults(results);
});
} else {
setResults([]);
}
}, [debouncedQuery]);
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search..."
/>
<div className="results">
{results.map((result) => (
<div key={result.id}>{result.title}</div>
))}
</div>
</div>
);
}
κ³ κΈ κΈ°λ₯
μν°ν°λ³ κ²μ
νΉμ μν°ν°λ₯Ό κ²μν©λλ€.
function EntitySearch() {
const [entityType, setEntityType] = useState<"users" | "posts">("users");
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
useEffect(() => {
if (query) {
const service = entityType === "users" ? userService : postService;
service.search({ query }).then(({ results }) => {
setResults(results);
});
}
}, [query, entityType]);
return (
<div>
<div className="entity-selector">
<button
className={entityType === "users" ? "active" : ""}
onClick={() => setEntityType("users")}
>
Users
</button>
<button
className={entityType === "posts" ? "active" : ""}
onClick={() => setEntityType("posts")}
>
Posts
</button>
</div>
<SearchInput
value={query}
onChange={setQuery}
placeholder={`Search ${entityType}...`}
/>
<div className="results">
{results.map((result) => (
<div key={result.id}>
{entityType === "users"
? result.username
: result.title
}
</div>
))}
</div>
</div>
);
}
νν°μ ν΅ν©
κ²μμ΄μ ν¨κ» νν°λ₯Ό μ μ©ν©λλ€.
function AdvancedSearch() {
const [query, setQuery] = useState("");
const [filters, setFilters] = useState({
category: null,
dateRange: null,
author: null,
});
const { results, isLoading } = useSearch({
query,
...filters,
});
return (
<div>
<div className="search-bar">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search posts..."
/>
</div>
<div className="filters">
<select
value={filters.category || ""}
onChange={(e) => setFilters({ ...filters, category: e.target.value || null })}
>
<option value="">All Categories</option>
<option value="tech">Tech</option>
<option value="news">News</option>
</select>
</div>
<div className="results">
{isLoading ? (
<div>Loading...</div>
) : (
results.map((result) => (
<div key={result.id}>{result.title}</div>
))
)}
</div>
</div>
);
}
κ²μ νμ€ν 리
μ΅κ·Ό κ²μμ΄λ₯Ό μ μ₯νκ³ νμν©λλ€.
function SearchWithHistory() {
const [query, setQuery] = useState("");
const [history, setHistory] = useState<string[]>([]);
// λ‘컬 μ€ν 리μ§μμ νμ€ν 리 λ‘λ
useEffect(() => {
const saved = localStorage.getItem("search-history");
if (saved) {
setHistory(JSON.parse(saved));
}
}, []);
function handleSearch(searchQuery: string) {
// νμ€ν 리μ μΆκ°
const newHistory = [searchQuery, ...history.filter(h => h !== searchQuery)].slice(0, 10);
setHistory(newHistory);
localStorage.setItem("search-history", JSON.stringify(newHistory));
// κ²μ μ€ν
setQuery(searchQuery);
}
return (
<SearchInput
value={query}
onChange={setQuery}
onSearch={handleSearch}
suggestions={query ? [] : history} // μ
λ ₯ μμΌλ©΄ νμ€ν 리 νμ
placeholder="Search..."
/>
);
}
νμ΄λΌμ΄ν
κ²μμ΄λ₯Ό κ²°κ³Όμμ κ°μ‘° νμν©λλ€.
function SearchResults({ results, query }: {
results: any[];
query: string;
}) {
function highlightText(text: string, highlight: string) {
if (!highlight) return text;
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<mark key={i}>{part}</mark>
) : (
part
)
);
}
return (
<div>
{results.map((result) => (
<div key={result.id}>
<h3>{highlightText(result.title, query)}</h3>
<p>{highlightText(result.content, query)}</p>
</div>
))}
</div>
);
}
컀μ€ν
Hook ν¨ν΄
useSearch Hook
κ²μ λ‘μ§μ μ¬μ¬μ© κ°λ₯ν HookμΌλ‘ μΆμνν©λλ€.
// hooks/useSearch.ts
import { useState, useEffect } from "react";
import { useDebounce } from "./useDebounce";
export function useSearch<T>(searchFn: (query: string) => Promise<T[]>, debounceMs: number = 300) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebounce(query, debounceMs);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
setIsLoading(true);
setError(null);
searchFn(debouncedQuery)
.then(setResults)
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [debouncedQuery]);
return {
query,
setQuery,
results,
isLoading,
error,
};
}
μ¬μ©:
function SearchPage() {
const { query, setQuery, results, isLoading } = useSearch(
(q) => postService.search({ query: q }).then(r => r.posts)
);
return (
<div>
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search posts..."
/>
{isLoading ? (
<div>Searching...</div>
) : (
<div>
{results.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
)}
</div>
);
}
useSearchSuggestions Hook
μλμμ± μ μμ κ΄λ¦¬ν©λλ€.
// hooks/useSearchSuggestions.ts
import { useState, useEffect } from "react";
import { useDebounce } from "./useDebounce";
export function useSearchSuggestions(
getSuggestionsFn: (query: string) => Promise<string[]>,
minLength: number = 2,
) {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const debouncedQuery = useDebounce(query, 200);
useEffect(() => {
if (debouncedQuery.length < minLength) {
setSuggestions([]);
return;
}
getSuggestionsFn(debouncedQuery).then(setSuggestions);
}, [debouncedQuery]);
return {
query,
setQuery,
suggestions,
};
}
μ€μ μμ
κΈλ‘λ² κ²μ
μ¬μ΄νΈ μ 체λ₯Ό κ²μν©λλ€.
function GlobalSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<{
users: User[];
posts: Post[];
comments: Comment[];
}>({
users: [],
posts: [],
comments: [],
});
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// μ¬λ¬ μν°ν° λμ κ²μ
Promise.all([
userService.search({ query: debouncedQuery }),
postService.search({ query: debouncedQuery }),
commentService.search({ query: debouncedQuery }),
]).then(([users, posts, comments]) => {
setResults({
users: users.users,
posts: posts.posts,
comments: comments.comments,
});
});
}
}, [debouncedQuery]);
return (
<div className="global-search">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search everything..."
/>
{query && (
<div className="results">
{results.users.length > 0 && (
<div className="result-section">
<h3>Users ({results.users.length})</h3>
{results.users.map((user) => (
<div key={user.id}>{user.username}</div>
))}
</div>
)}
{results.posts.length > 0 && (
<div className="result-section">
<h3>Posts ({results.posts.length})</h3>
{results.posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
)}
{results.comments.length > 0 && (
<div className="result-section">
<h3>Comments ({results.comments.length})</h3>
{results.comments.map((comment) => (
<div key={comment.id}>{comment.content}</div>
))}
</div>
)}
</div>
)}
</div>
);
}
λ€λΉκ²μ΄μ
κ²μ
ν€λμ ν΅ν©λ κ²μμ
λλ€.
function Header() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const { query, setQuery, results, isLoading } = useSearch(
(q) => searchService.globalSearch({ query: q })
);
return (
<header>
<div className="logo">My App</div>
<div className="search-container">
{/* κ²μ μμ΄μ½ */}
<button onClick={() => setIsSearchOpen(!isSearchOpen)}>
π
</button>
{/* κ²μ λͺ¨λ¬ */}
{isSearchOpen && (
<div className="search-modal">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Search..."
autoFocus
/>
{isLoading && <div>Loading...</div>}
{results.length > 0 && (
<div className="quick-results">
{results.slice(0, 5).map((result) => (
<a
key={result.id}
href={`/${result.type}/${result.id}`}
onClick={() => setIsSearchOpen(false)}
>
{result.title}
</a>
))}
</div>
)}
</div>
)}
</div>
<nav>{/* λ€λΉκ²μ΄μ
λ©λ΄ */}</nav>
</header>
);
}
μ±λ₯ μ΅μ ν
λλ°μ΄μ€ μ΅μ ν
κ²μ λΉλμ λ°λΌ λλ°μ΄μ€ μκ°μ μ‘°μ ν©λλ€.
// λΉ λ₯Έ νμ΄ν: μ§§μ λλ°μ΄μ€
const fastDebounce = useDebounce(query, 200);
// λλ¦° νμ΄ν: κΈ΄ λλ°μ΄μ€
const slowDebounce = useDebounce(query, 500);
// API μλ΅μ΄ λλ¦° κ²½μ°: λ κΈ΄ λλ°μ΄μ€
const longDebounce = useDebounce(query, 1000);
μΊμ± μ λ΅
TanStack Queryλ‘ κ²μ κ²°κ³Όλ₯Ό μΊμ±ν©λλ€.
import { useQuery } from "@tanstack/react-query";
function useSearchWithCache(query: string) {
const debouncedQuery = useDebounce(query, 300);
const { data, error, isLoading } = useQuery({
queryKey: ["search", debouncedQuery],
queryFn: () => searchService.search({ query: debouncedQuery }),
enabled: !!debouncedQuery,
staleTime: 5000, // 5μ΄κ° fresh μν μ μ§
refetchOnWindowFocus: false, // ν¬μ»€μ€ μ μ¬κ²μ¦ μν¨
});
return {
results: data?.results || [],
isLoading,
error,
};
}
μμ² μ·¨μ
μ»΄ν¬λνΈ μΈλ§μ΄νΈ μ μ§ν μ€μΈ μμ²μ μ·¨μν©λλ€.
useEffect(() => {
const abortController = new AbortController();
if (debouncedQuery) {
searchService
.search({ query: debouncedQuery }, { signal: abortController.signal })
.then(setResults);
}
return () => {
abortController.abort();
};
}, [debouncedQuery]);
μ£Όμμ¬ν
Search Input μ¬μ© μ μ£Όμμ¬ν: 1. λλ°μ΄μ€ νμ: 300500ms κΆμ₯ 2. μ΅μ κ²μ κΈΈμ΄ μ€μ
(λ³΄ν΅ 23μ) 3. λΉ λ¬Έμμ΄ μ²λ¦¬: λͺ¨λ κ²°κ³Ό λ°ν λ°©μ§ 4. ν€λ³΄λ λ€λΉκ²μ΄μ
μ§μ (μ κ·Όμ±) 5.
κ²μ APIλ μΈλ±μ€ μ΅μ ν νμ 6. κ²°κ³Όκ° λ§μΌλ©΄ νμ΄μ§λ€μ΄μ
κ³ λ €
λ€μ λ¨κ³
ID Async Select
κ΄κ³ λ°μ΄ν° μ ν
컀μ€ν
μ»΄ν¬λνΈ
μ»΄ν¬λνΈ μ»€μ€ν°λ§μ΄μ§
View Scaffolding
μλ λ·° μμ±
TanStack Query Hook
λ°μ΄ν° μΊμ±