Prerequisites
- Strong TypeScript fundamentals ๐
- React hooks knowledge (useState, useCallback, useMemo) โ๏ธ
- Basic understanding of Astro ๐
- Familiarity with REST APIs ๐
What you'll learn
- Build a performant real-time search interface ๐
- Implement TypeScript interfaces for search results ๐
- Create keyboard navigation and accessibility features โจ๏ธ
- Optimize search performance with debouncing and caching ๐
๐ฏ Introduction
Welcome to this exciting tutorial on building a real-time search feature! ๐ In this guide, weโll create a powerful search system that can instantly find content across your entire application.
Youโll discover how TypeScript, React, and Astro work together to create a search experience thatโs not just functional, but delightful to use. Whether youโre building a documentation site ๐, a blog platform ๐, or an e-commerce store ๐, understanding how to implement fast, type-safe search is essential!
By the end of this tutorial, youโll have built a production-ready search feature with autocomplete, keyboard navigation, and beautiful animations! Letโs dive in! ๐โโ๏ธ
๐ Understanding Real-Time Search
๐ค What Makes Search โReal-Timeโ?
Real-time search is like having a super-smart assistant ๐ค who starts looking for what you want the moment you start typing. Think of it as the difference between sending a letter (traditional search) and having a live conversation (real-time search)!
In TypeScript terms, real-time search means:
- โจ Instant feedback as users type
- ๐ Debounced API calls for performance
- ๐ก๏ธ Type-safe data flow from API to UI
- โก Optimized rendering with React hooks
๐ก Why TypeScript for Search?
Hereโs why TypeScript makes search features amazing:
- Type Safety ๐: Know exactly what data youโre getting
- Autocomplete ๐ป: IDE helps you build faster
- Refactoring Confidence ๐ง: Change without breaking
- Self-Documenting ๐: Types explain the data flow
Real-world example: Imagine searching for products ๐. With TypeScript, you know exactly what fields each product has, preventing those dreaded โundefinedโ errors!
๐ง Basic Search Architecture
๐ Setting Up Our Types
Letโs start by defining our search systemโs types:
// ๐ฏ Define what a search result looks like
interface SearchResult {
id: string; // ๐ Unique identifier
title: string; // ๐ Main title
description: string; // ๐ Brief description
url: string; // ๐ Where to navigate
type: 'blog' | 'tutorial' | 'product'; // ๐ท๏ธ Content type
relevanceScore: number; // ๐ฏ How well it matches (0-100)
metadata?: { // ๐ Optional extra data
author?: string;
publishDate?: string;
category?: string;
tags?: string[];
};
}
// ๐ API response structure
interface SearchResponse {
success: boolean; // โ
Did it work?
data: SearchResult[]; // ๐ The results
query: string; // ๐ What was searched
total: number; // ๐ Total matches found
executionTime?: number; // โฑ๏ธ How long it took
}
// ๐จ Search UI state
interface SearchState {
query: string; // ๐ฌ Current search text
results: SearchResult[]; // ๐ Current results
isLoading: boolean; // โณ Loading indicator
error: string | null; // โ ๏ธ Error messages
selectedIndex: number; // ๐ Keyboard navigation
}
๐ก Explanation: Notice how we use union types for type
and optional properties with ?
. This gives us flexibility while maintaining type safety!
๐ฏ Creating the Search Hook
Hereโs a custom React hook for search functionality:
// ๐ช Custom hook for search logic
import { useState, useCallback, useRef, useEffect } from 'react';
function useSearch(debounceMs = 300) {
// ๐จ State management
const [state, setState] = useState<SearchState>({
query: '',
results: [],
isLoading: false,
error: null,
selectedIndex: -1
});
// ๐ Refs for cleanup
const abortControllerRef = useRef<AbortController | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// ๐ The search function
const performSearch = useCallback(async (searchQuery: string) => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(searchQuery)}`,
{ signal: abortControllerRef.current.signal }
);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data: SearchResponse = await response.json();
if (data.success) {
setState(prev => ({
...prev,
results: data.data,
isLoading: false
}));
} else {
throw new Error('Search returned unsuccessful');
}
} catch (error) {
// Ignore aborted requests
if (error instanceof Error && error.name !== 'AbortError') {
setState(prev => ({
...prev,
error: error.message,
isLoading: false
}));
}
}
}, []);
// ๐ฏ Debounced search
const search = useCallback((query: string) => {
setState(prev => ({ ...prev, query }));
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Don't search for very short queries
if (query.trim().length < 2) {
setState(prev => ({ ...prev, results: [], error: null }));
return;
}
// Debounce the search
debounceTimerRef.current = setTimeout(() => {
performSearch(query);
}, debounceMs);
}, [performSearch, debounceMs]);
// ๐งน Cleanup on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return { state, search };
}
๐ก Building the Search UI
๐ Example 1: Search Modal Component
Letโs build a beautiful search modal:
// ๐จ Search Modal with all the bells and whistles!
import React, { useCallback, useEffect, memo } from 'react';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
}
export const SearchModal: React.FC<SearchModalProps> = memo(({
isOpen,
onClose
}) => {
const { state, search } = useSearch(300);
const inputRef = useRef<HTMLInputElement>(null);
// ๐ฏ Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowDown':
e.preventDefault();
setState(prev => ({
...prev,
selectedIndex: Math.min(
prev.selectedIndex + 1,
prev.results.length - 1
)
}));
break;
case 'ArrowUp':
e.preventDefault();
setState(prev => ({
...prev,
selectedIndex: Math.max(prev.selectedIndex - 1, -1)
}));
break;
case 'Enter':
e.preventDefault();
if (state.selectedIndex >= 0) {
const selected = state.results[state.selectedIndex];
if (selected) {
window.location.href = selected.url;
}
}
break;
}
}, [state.selectedIndex, state.results, onClose]);
// ๐ฏ Focus input when modal opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
// ๐ซ Don't render if not open
if (!isOpen) return null;
return (
<div className="search-modal-overlay" onClick={onClose}>
<div className="search-modal" onClick={e => e.stopPropagation()}>
{/* ๐ Search Input */}
<div className="search-input-wrapper">
<span className="search-icon">๐</span>
<input
ref={inputRef}
type="text"
placeholder="Search for anything..."
value={state.query}
onChange={e => search(e.target.value)}
onKeyDown={handleKeyDown}
className="search-input"
/>
{state.isLoading && <span className="loading-spinner">โณ</span>}
</div>
{/* ๐ Search Results */}
<div className="search-results">
{state.error && (
<div className="error-message">
โ ๏ธ {state.error}
</div>
)}
{state.results.length === 0 && state.query.length >= 2 && !state.isLoading && (
<div className="no-results">
๐ No results found for "{state.query}"
</div>
)}
{state.results.map((result, index) => (
<SearchResultItem
key={result.id}
result={result}
isSelected={index === state.selectedIndex}
onClick={() => window.location.href = result.url}
/>
))}
</div>
{/* โจ๏ธ Keyboard shortcuts help */}
<div className="search-footer">
<span>โโ Navigate</span>
<span>โต Select</span>
<span>ESC Close</span>
</div>
</div>
</div>
);
});
// ๐ฏ Individual search result component
const SearchResultItem: React.FC<{
result: SearchResult;
isSelected: boolean;
onClick: () => void;
}> = memo(({ result, isSelected, onClick }) => {
const getTypeEmoji = (type: SearchResult['type']) => {
switch (type) {
case 'blog': return '๐';
case 'tutorial': return '๐';
case 'product': return '๐';
default: return '๐';
}
};
return (
<div
className={`search-result ${isSelected ? 'selected' : ''}`}
onClick={onClick}
>
<span className="result-emoji">{getTypeEmoji(result.type)}</span>
<div className="result-content">
<h3>{result.title}</h3>
<p>{result.description}</p>
{result.metadata?.tags && (
<div className="result-tags">
{result.metadata.tags.map(tag => (
<span key={tag} className="tag">#{tag}</span>
))}
</div>
)}
</div>
<span className="relevance-score">
{result.relevanceScore}% match
</span>
</div>
);
});
๐ฎ Example 2: Astro API Route
Letโs create the backend search API:
// ๐ src/pages/api/search.ts
import type { APIRoute } from 'astro';
// ๐ฏ Search algorithm with scoring
export const GET: APIRoute = async ({ url }) => {
const query = url.searchParams.get('q');
const limit = parseInt(url.searchParams.get('limit') || '10');
// ๐ก๏ธ Input validation
if (!query || query.trim().length < 2) {
return new Response(JSON.stringify({
success: false,
error: 'Query must be at least 2 characters',
data: [],
query: query || '',
total: 0
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// ๐ Perform search (example implementation)
const startTime = performance.now();
const results = await searchContent(query.trim(), limit);
const executionTime = performance.now() - startTime;
return new Response(JSON.stringify({
success: true,
data: results,
query: query.trim(),
total: results.length,
executionTime: Math.round(executionTime)
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=60' // Cache for 1 minute
}
});
} catch (error) {
console.error('Search error:', error);
return new Response(JSON.stringify({
success: false,
error: 'Search failed',
data: [],
query: query.trim(),
total: 0
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};
// ๐ Scoring algorithm
async function searchContent(
query: string,
limit: number
): Promise<SearchResult[]> {
const searchTerms = query.toLowerCase().split(' ').filter(Boolean);
// Get all content (this would come from your database)
const allContent = await getAllContent();
// Score each item
const scoredResults = allContent.map(item => {
let score = 0;
// Title matches (highest weight)
searchTerms.forEach(term => {
if (item.title.toLowerCase().includes(term)) {
score += 10;
// Exact match bonus
if (item.title.toLowerCase() === query.toLowerCase()) {
score += 20;
}
}
});
// Description matches
searchTerms.forEach(term => {
if (item.description.toLowerCase().includes(term)) {
score += 5;
}
});
// Tag matches
item.tags?.forEach(tag => {
searchTerms.forEach(term => {
if (tag.toLowerCase().includes(term)) {
score += 3;
}
});
});
return {
...item,
relevanceScore: Math.min(100, score * 2) // Normalize to 0-100
};
});
// Filter and sort
return scoredResults
.filter(result => result.relevanceScore > 0)
.sort((a, b) => b.relevanceScore - a.relevanceScore)
.slice(0, limit);
}
๐ Advanced Search Features
๐งโโ๏ธ Fuzzy Search Implementation
When youโre ready to level up, try fuzzy matching:
// ๐ฏ Advanced fuzzy search with Levenshtein distance
type FuzzySearchOptions = {
threshold: number; // ๐๏ธ Matching threshold (0-1)
caseSensitive: boolean; // ๐ค Case sensitivity
sortByRelevance: boolean; // ๐ Sort results
};
class FuzzySearcher<T extends Record<string, any>> {
private items: T[];
private searchableKeys: (keyof T)[];
private options: FuzzySearchOptions;
constructor(
items: T[],
searchableKeys: (keyof T)[],
options: Partial<FuzzySearchOptions> = {}
) {
this.items = items;
this.searchableKeys = searchableKeys;
this.options = {
threshold: 0.6,
caseSensitive: false,
sortByRelevance: true,
...options
};
}
// ๐ Main search method
search(query: string): Array<T & { score: number }> {
if (!query) return [];
const results = this.items.map(item => {
const score = this.calculateItemScore(item, query);
return { ...item, score };
}).filter(item => item.score >= this.options.threshold);
if (this.options.sortByRelevance) {
results.sort((a, b) => b.score - a.score);
}
return results;
}
// ๐ฏ Calculate relevance score
private calculateItemScore(item: T, query: string): number {
let maxScore = 0;
for (const key of this.searchableKeys) {
const value = String(item[key] || '');
const score = this.fuzzyMatch(
this.options.caseSensitive ? value : value.toLowerCase(),
this.options.caseSensitive ? query : query.toLowerCase()
);
maxScore = Math.max(maxScore, score);
}
return maxScore;
}
// ๐ช Fuzzy matching algorithm
private fuzzyMatch(text: string, query: string): number {
if (text === query) return 1; // Perfect match
if (text.includes(query)) return 0.9; // Contains match
// Calculate similarity
const distance = this.levenshteinDistance(text, query);
const maxLength = Math.max(text.length, query.length);
return 1 - (distance / maxLength);
}
// ๐ Levenshtein distance calculation
private levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
// Initialize matrix
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
}
// ๐ Using the fuzzy searcher
const products = [
{ id: '1', name: 'TypeScript Handbook', category: 'Books' },
{ id: '2', name: 'JavaScript Guide', category: 'Books' },
{ id: '3', name: 'Type Safety Course', category: 'Courses' }
];
const searcher = new FuzzySearcher(products, ['name', 'category'], {
threshold: 0.5,
caseSensitive: false
});
// Will find "TypeScript Handbook" even with typo!
const results = searcher.search('typscript');
๐๏ธ Search Result Caching
For ultimate performance, implement caching:
// ๐ Advanced caching system
class SearchCache {
private cache = new Map<string, {
results: SearchResult[];
timestamp: number;
}>();
private maxAge: number; // milliseconds
private maxSize: number;
constructor(maxAge = 5 * 60 * 1000, maxSize = 100) {
this.maxAge = maxAge;
this.maxSize = maxSize;
}
// ๐ฅ Get cached results
get(query: string): SearchResult[] | null {
const cached = this.cache.get(query);
if (!cached) return null;
// Check if expired
if (Date.now() - cached.timestamp > this.maxAge) {
this.cache.delete(query);
return null;
}
return cached.results;
}
// ๐ค Store results
set(query: string, results: SearchResult[]): void {
// Enforce size limit
if (this.cache.size >= this.maxSize) {
// Remove oldest entry
const oldestKey = Array.from(this.cache.entries())
.sort(([, a], [, b]) => a.timestamp - b.timestamp)[0][0];
this.cache.delete(oldestKey);
}
this.cache.set(query, {
results,
timestamp: Date.now()
});
}
// ๐งน Clear cache
clear(): void {
this.cache.clear();
}
// ๐ Get cache stats
getStats(): {
size: number;
queries: string[];
oldestEntry: number | null;
} {
const entries = Array.from(this.cache.entries());
return {
size: this.cache.size,
queries: entries.map(([query]) => query),
oldestEntry: entries.length > 0
? Math.min(...entries.map(([, data]) => data.timestamp))
: null
};
}
}
// ๐ฏ Using cache in search hook
function useSearchWithCache(debounceMs = 300) {
const cacheRef = useRef(new SearchCache());
const performSearch = useCallback(async (query: string) => {
// Check cache first
const cached = cacheRef.current.get(query);
if (cached) {
setState(prev => ({
...prev,
results: cached,
isLoading: false
}));
return;
}
// Perform search...
const results = await fetchSearchResults(query);
// Cache the results
cacheRef.current.set(query, results);
// Update state...
}, []);
}
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: The Race Condition Trap
// โ Wrong way - race conditions galore!
const BadSearch = () => {
const [results, setResults] = useState([]);
const search = async (query: string) => {
const data = await fetch(`/api/search?q=${query}`);
setResults(data); // ๐ฅ What if user typed something else?
};
};
// โ
Correct way - handle race conditions!
const GoodSearch = () => {
const [results, setResults] = useState([]);
const latestQueryRef = useRef('');
const search = async (query: string) => {
latestQueryRef.current = query;
const data = await fetch(`/api/search?q=${query}`);
// Only update if this is still the latest query
if (query === latestQueryRef.current) {
setResults(data); // โ
Safe from race conditions!
}
};
};
๐คฏ Pitfall 2: Memory Leaks in Search
// โ Dangerous - memory leak waiting to happen!
const LeakySearch = () => {
const [results, setResults] = useState<SearchResult[]>([]);
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchPopularSearches();
setResults(prev => [...prev, ...data]); // ๐ฅ Grows forever!
}, 5000);
// Forgot to clean up! ๐ฑ
}, []);
};
// โ
Safe - proper cleanup and limits!
const SafeSearch = () => {
const [results, setResults] = useState<SearchResult[]>([]);
const maxResults = 100;
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchPopularSearches();
setResults(prev => {
const combined = [...prev, ...data];
return combined.slice(-maxResults); // โ
Limit size!
});
}, 5000);
return () => clearInterval(interval); // โ
Clean up!
}, []);
};
๐ ๏ธ Best Practices
- ๐ฏ Debounce Wisely: 200-300ms is the sweet spot
- ๐ Clear Types: Define all data structures upfront
- ๐ก๏ธ Handle Errors: Always show user-friendly messages
- ๐จ Visual Feedback: Loading states are crucial
- โจ Keyboard Support: Make it fully navigable
- ๐ Cache Smart: Balance freshness vs performance
- ๐ Track Metrics: Measure search performance
๐งช Hands-On Exercise
๐ฏ Challenge: Build an Advanced Search System
Create a search system with these features:
๐ Requirements:
- โ Fuzzy matching for typos
- ๐ท๏ธ Filter by content type
- ๐ค Search history with localStorage
- ๐ Date range filtering
- ๐จ Highlighted search terms in results!
๐ Bonus Points:
- Voice search integration
- Search analytics tracking
- Multi-language support
- Instant search suggestions
๐ก Solution
๐ Click to see solution
// ๐ฏ Complete advanced search implementation!
interface AdvancedSearchOptions {
query: string;
filters: {
type?: string[];
dateFrom?: Date;
dateTo?: Date;
tags?: string[];
};
fuzzyMatch: boolean;
highlightTerms: boolean;
}
class AdvancedSearchSystem {
private searchHistory: string[] = [];
private fuzzySearcher: FuzzySearcher<any>;
private cache = new SearchCache();
constructor() {
// Load search history
this.loadSearchHistory();
// Initialize fuzzy searcher
this.fuzzySearcher = new FuzzySearcher([], ['title', 'description']);
}
// ๐ Main search method
async search(options: AdvancedSearchOptions): Promise<SearchResult[]> {
// Save to history
this.addToHistory(options.query);
// Check cache
const cacheKey = JSON.stringify(options);
const cached = this.cache.get(cacheKey);
if (cached) return cached;
// Perform search
let results = await this.performSearch(options);
// Apply filters
results = this.applyFilters(results, options.filters);
// Highlight terms if requested
if (options.highlightTerms) {
results = this.highlightSearchTerms(results, options.query);
}
// Cache results
this.cache.set(cacheKey, results);
return results;
}
// ๐ฏ Fuzzy search implementation
private async performSearch(
options: AdvancedSearchOptions
): Promise<SearchResult[]> {
if (options.fuzzyMatch) {
// Use fuzzy searcher
const allContent = await this.getAllContent();
this.fuzzySearcher = new FuzzySearcher(
allContent,
['title', 'description']
);
return this.fuzzySearcher.search(options.query);
} else {
// Regular search
return this.regularSearch(options.query);
}
}
// ๐ท๏ธ Apply filters
private applyFilters(
results: SearchResult[],
filters: AdvancedSearchOptions['filters']
): SearchResult[] {
return results.filter(result => {
// Type filter
if (filters.type?.length && !filters.type.includes(result.type)) {
return false;
}
// Date range filter
if (filters.dateFrom || filters.dateTo) {
const resultDate = new Date(result.metadata?.publishDate || '');
if (filters.dateFrom && resultDate < filters.dateFrom) return false;
if (filters.dateTo && resultDate > filters.dateTo) return false;
}
// Tag filter
if (filters.tags?.length) {
const resultTags = result.metadata?.tags || [];
const hasMatchingTag = filters.tags.some(tag =>
resultTags.includes(tag)
);
if (!hasMatchingTag) return false;
}
return true;
});
}
// โจ Highlight search terms
private highlightSearchTerms(
results: SearchResult[],
query: string
): SearchResult[] {
const terms = query.toLowerCase().split(' ').filter(Boolean);
return results.map(result => ({
...result,
title: this.highlightText(result.title, terms),
description: this.highlightText(result.description, terms)
}));
}
private highlightText(text: string, terms: string[]): string {
let highlighted = text;
terms.forEach(term => {
const regex = new RegExp(`(${term})`, 'gi');
highlighted = highlighted.replace(
regex,
'<mark class="search-highlight">$1</mark>'
);
});
return highlighted;
}
// ๐ Search history management
private addToHistory(query: string): void {
if (!query.trim()) return;
// Remove if already exists
this.searchHistory = this.searchHistory.filter(q => q !== query);
// Add to beginning
this.searchHistory.unshift(query);
// Limit to 10 items
this.searchHistory = this.searchHistory.slice(0, 10);
// Save to localStorage
this.saveSearchHistory();
}
private loadSearchHistory(): void {
try {
const saved = localStorage.getItem('searchHistory');
if (saved) {
this.searchHistory = JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load search history:', error);
}
}
private saveSearchHistory(): void {
try {
localStorage.setItem(
'searchHistory',
JSON.stringify(this.searchHistory)
);
} catch (error) {
console.error('Failed to save search history:', error);
}
}
// ๐ค Voice search support
async startVoiceSearch(): Promise<string> {
return new Promise((resolve, reject) => {
if (!('webkitSpeechRecognition' in window)) {
reject(new Error('Speech recognition not supported'));
return;
}
const recognition = new (window as any).webkitSpeechRecognition();
recognition.lang = 'en-US';
recognition.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
resolve(transcript);
};
recognition.onerror = reject;
recognition.start();
});
}
}
// ๐ฎ Using the advanced search system
const searchSystem = new AdvancedSearchSystem();
// Example search
const results = await searchSystem.search({
query: 'typescript tutorial',
filters: {
type: ['tutorial'],
dateFrom: new Date('2024-01-01'),
tags: ['beginner', 'intermediate']
},
fuzzyMatch: true,
highlightTerms: true
});
console.log('๐ Found', results.length, 'results!');
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Build real-time search with TypeScript and React ๐ช
- โ Implement fuzzy matching for typo-tolerant search ๐ก๏ธ
- โ Create accessible interfaces with keyboard navigation ๐ฏ
- โ Optimize performance with debouncing and caching ๐
- โ Handle edge cases like race conditions properly ๐
Remember: Great search isnโt just about finding thingsโitโs about creating an experience that feels magical! ๐ช
๐ค Next Steps
Congratulations! ๐ Youโve mastered building real-time search with TypeScript!
Hereโs what to do next:
- ๐ป Build the search system from the exercise
- ๐๏ธ Add it to your own project
- ๐ Explore full-text search databases like Elasticsearch
- ๐ Share your implementation with the community!
Remember: Every great app started with someone typing into a search box. Now you can build that experience too! Keep coding, keep searching, and most importantly, have fun! ๐
Happy searching! ๐๐โจ