
No Comments Yet
Be the first to share your thoughts and start the conversation.

Be the first to share your thoughts and start the conversation.

You have built the charts, the data tables, and the pagination. Now, you face the most dynamic component of any application: The Global Search.
In a tutorial, I would just teach you the code functionality and you’ll "follow-along" blindly. But in the real world, you get a set of requirements and your mission is to connect the dots and implement that functionality.
Your Goal is to Build a SearchModal that opens with a keyboard shortcut (Cmd+K), searches for coins via API as the user types, handles loading states gracefully, and allows navigation to the coin's detail page.
Before you write a single line of code, you must understand tools through which you’ll be building this feature. You aren't just using standard React state here; you are using industry-standard patterns for performance and user experience.
You might be tempted to use a simple useEffect to fetch data when the input changes. Don't.
useEffect for data fetching leads to "race conditions." If a user types "Bit", then "Bitc", then "Bitco" really fast, the responses might arrive out of order. You might end up showing results for "Bit" after the results for "Bitco" have loaded. It's a mess.
And that’s where libraries like SWR comes into the picture.
SWR stands for Stale-While-Revalidate. It is a library (created by Vercel) that handles data fetching with a specific strategy:
Why use it here?
Key Concept: The key passed to useSWR acts like a dependency array. If the key changes (e.g., from 'search/bit' to 'search/bitcoin'), SWR triggers a new fetch. If the key is null, SWR pauses and does nothing.
I have provided a helper function called searchCoins in video kit (lib/coingecko.actions.ts). You don't need to write this from scratch, but you must understand how it works to use it effectively.
The Problem it Solves:
The standard CoinGecko Search API (/search) gives you a list of coin names and symbols, but it does not give you the current price. Users expect to see the price in the search dropdown.
The Logic Inside searchCoins:
It performs a "Two-Step Data Merge" behind the scenes:
Extraction: It takes the IDs of the top 10 results.
Fetch 2: It calls /coins/markets passing those specific IDs. This endpoint does return price data.
Your Task: Import this function. You will pass it to SWR as the "fetcher" function.
You are using the cmdk library (wrapped by Shadcn UI). This isn't just a styled list; it's an accessible command palette engine. It handles:
Users these days don't just click icons; they like to press keys. You need to listen for Cmd+K (Mac) or Ctrl+K (Windows) globally.
If you search on every keystroke, you will hit CoinGecko's rate limit in seconds. You need to wait until the user stops typing.
Your modal needs to be smart. It shouldn't just be blank when opened.
How did you do? Did you remember to handle the null key in SWR to pause fetching? Did you separate the Search Item into its own component to keep things clean?
components/SearchModal.tsx
'use client';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Button } from './ui/button';
import { searchCoins } from '@/lib/coingecko.actions';
import { Search as SearchIcon, TrendingDown, TrendingUp } from 'lucide-react';
import { useState } from 'react';
import { cn, formatPercentage } from '@/lib/utils';
import useSWR from 'swr';
import { useDebounce, useKey } from 'react-use';
const TRENDING_LIMIT = 8;
const SEARCH_LIMIT = 10;
const SearchItem = ({ coin, onSelect, isActiveName }: SearchItemProps) => {
const isSearchCoin =
typeof coin.data?.price_change_percentage_24h === 'number';
const change = isSearchCoin
? (coin as SearchCoin).data?.price_change_percentage_24h ?? 0
: (coin as TrendingCoin['item']).data.price_change_percentage_24h?.usd ?? 0;
return (
<CommandItem
value={coin.id}
onSelect={() => onSelect(coin.id)}
className='search-item'
>
<div className='coin-info'>
<Image src={coin.thumb} alt={coin.name} width={40} height={40} />
<div>
<p className={cn('font-bold', isActiveName && 'text-white')}>
{coin.name}
</p>
<p className='coin-symbol'>{coin.symbol}</p>
</div>
</div>
<div
className={cn('coin-change', {
'text-green-500': change > 0,
'text-red-500': change < 0,
})}
>
{change > 0 ? (
<TrendingUp size={14} className='text-green-500' />
) : (
<TrendingDown size={14} className='text-red-500' />
)}
<span>{formatPercentage(Math.abs(change))}</span>
</div>
</CommandItem>
);
};
export const SearchModal = ({
initialTrendingCoins = [],
}: {
initialTrendingCoins: TrendingCoin[];
}) => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
useDebounce(
() => {
setDebouncedQuery(searchQuery.trim());
},
300,
[searchQuery]
);
const { data: searchResults = [], isValidating: isSearching } = useSWR<
SearchCoin[]
>(
debouncedQuery ? ['coin-search', debouncedQuery] : null,
([, query]) => searchCoins(query as string),
{
revalidateOnFocus: false,
}
);
useKey(
(event) =>
event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey),
(event) => {
event.preventDefault();
setOpen((prev) => !prev);
},
{},
[setOpen]
);
const handleSelect = (coinId: string) => {
setOpen(false);
setSearchQuery('');
setDebouncedQuery('');
router.push(`/coins/${coinId}`);
};
const hasQuery = debouncedQuery.length > 0;
const trendingCoins = initialTrendingCoins.slice(0, TRENDING_LIMIT);
const showTrending = !hasQuery && trendingCoins.length > 0;
const isSearchEmpty = !isSearching && !hasQuery && !showTrending;
const isTrendingListVisible = !isSearching && showTrending;
const isNoResults = !isSearching && hasQuery && searchResults.length === 0;
const isResultsVisible = !isSearching && hasQuery && searchResults.length > 0;
return (
<div id='search-modal'>
<Button variant='ghost' onClick={() => setOpen(true)} className='trigger'>
<SearchIcon size={18} />
Search
<kbd className='kbd'>
<span className='text-xs'>⌘</span>K
</kbd>
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
className='dialog'
data-search-modal
>
<div className='cmd-input'>
<CommandInput
placeholder='Search for a token by name or symbol...'
value={searchQuery}
onValueChange={setSearchQuery}
/>
</div>
<CommandList className='list custom-scrollbar'>
{isSearching && <div className='empty'>Searching...</div>}
{isSearchEmpty && (
<div className='empty'>Type to search for coins...</div>
)}
{isTrendingListVisible && (
<CommandGroup className='group'>
{trendingCoins.map(({ item }) => (
<SearchItem
key={item.id}
coin={item}
onSelect={handleSelect}
isActiveName={false}
/>
))}
</CommandGroup>
)}
{isNoResults && <CommandEmpty>No coins found.</CommandEmpty>}
{isResultsVisible && (
<CommandGroup
heading={<p className='heading'>Search Results</p>}
className='group'
>
{searchResults.slice(0, SEARCH_LIMIT).map((coin) => (
<SearchItem
key={coin.id}
coin={coin}
onSelect={handleSelect}
isActiveName
/>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
</div>
);
};This is the active part. Open your code editor. Create a file named components/SearchModal.tsx.
Your requirements:
Go build it. Struggle with the imports. Figure out why the modal won't open. This struggle is where the learning happens.