Build a Country Explorer with React
Build a React app that fetches and displays live country data.
Introduction
30 Second Summary
Every time you browse a travel site or scroll through world statistics, you're looking at data fetched from an API and displayed as interactive cards. That's exactly the kind of app frontend developers build every day.
In this project, you will build a Country Explorer app using React that fetches real country data and displays it in a searchable, filterable card grid.
What You'll Build
A React application that pulls live country data (flags, population, region, capital, languages) from the REST Countries API and renders it as an interactive card grid with search and filter controls.
By the end of this project, you'll have:
- A responsive grid of country cards showing each country's flag, name, population, region, and capital.
- A custom hook that encapsulates all data fetching logic with loading and error states.
- Real-time search and filter functionality powered by React state and derived data.
- Secret Mission: a favorites feature with localStorage persistence so users can save countries and view them in a separate tab.
Are there any prerequisites?
You should be comfortable with basic JavaScript (variables, functions, arrays, objects). No prior React experience is needed.
Before We Start
You're about to build a Country Explorer app that fetches real country data from a live API and displays it in a searchable, filterable card grid. React is the library that makes this kind of interactive UI possible.
Before writing any React code, you need three things in place: a JavaScript runtime, a code editor, and a project scaffold. This step gets all three running on your machine.
In this step, get ready to:
- Install Node.js v24 LTS and verify it works.
- Open Google Antigravity IDE and its integrated terminal.
- Scaffold a React app with Vite and start the dev server.
Install Node.js v24 LTS
Node.js is the JavaScript runtime that lets you run JavaScript outside of a browser. Both Vite (your build tool) and npm (your package manager) rely on it.
- Download the Node.js v24 LTS installer from the official download page.
- Run the installer and accept the default options.
- Once installation finishes, open your terminal to verify it worked.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check your Node.js version by running this command:
node --version
✔️ I see version 24 or higher
Your terminal shows something like v24.16.0. Node.js is ready to go.
ⓧ I see an older version
Your terminal shows a version number lower than 24 (for example, v20.11.0 or v18.17.0). You need to upgrade.
- Go to the Node.js download page.
- Download the v24 LTS installer for your operating system.
- Run the installer. It will replace your older version.
- Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Go to the Node.js download page.
- Download the v24 LTS installer for your operating system.
- Run the installer and accept the defaults.
- Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
- Verify npm is also available by running:
npm --version
You should see a version number in your terminal output. npm ships alongside Node.js, so it should already be installed.
npm command not found?
If node --version works but npm --version does not, try closing and reopening your terminal. If the issue persists, uninstall Node.js and reinstall it from the official download page.
Help me troubleshoot my npm installation
Open Google Antigravity IDE
Google Antigravity is an AI-powered IDE built on the same foundation as VS Code. It has a full code editor, integrated terminal, and file explorer.
✔️ I already have Google Antigravity installed
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Antigravity and press Enter to open it.
ⓧ I don't have it installed yet
- Go to antigravity.google/download and download the Antigravity IDE for your operating system.
- Run the installer. If you see a prompt asking whether to Keep Both or Replace, select Replace.
- Once installed, press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Antigravity and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
What is the integrated terminal?
The integrated terminal is a command-line interface built right into the IDE. It works exactly like the standalone terminal you used earlier, but lives inside the editor window so you can run commands and see your code side by side.
Scaffold the React project with Vite
Vite is a modern build tool that creates a ready-to-use project structure for you. The scaffold command generates all the starter files, folders, and configuration your React app needs.
- Move to your Desktop by running this command in the integrated terminal:
cd ~/Desktop
- Create the project by running this scaffold command:
npm create vite@latest country-explorer -- --template react
What does this command do?
npm create vite@latest runs the latest version of Vite's project generator. country-explorer is the name of the folder it creates.
The -- separates npm arguments from Vite arguments. --template react tells Vite to use its React JavaScript template, which pre-configures everything for React development.
- Move into the new project folder and install its dependencies by running these commands:
cd country-explorer
npm install
You should see a progress bar followed by a summary of installed packages.
- Start the development server by running:
npm run dev
- Open your browser and go to http://localhost:5173.
You should see the Vite + React starter page with the Vite and React logos, a counter button, and a heading.
Don't see the starter page?
Make sure the terminal still shows the dev server running. Look for a line that includes Local: http://localhost:5173 in the output.
If the server stopped, make sure you are inside the country-explorer folder and run npm run dev again.
Help me get my Vite dev server running
- Back in Google Antigravity, open the country-explorer folder so the IDE can see your project files.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the country-explorer folder, and click Open.
Your IDE's file explorer (left sidebar) should now show the project files including src/, package.json, and vite.config.js.
Why Vite instead of Create React App?
Vite provides a minimal setup with zero framework opinions. It starts the dev server almost instantly and updates the browser in real time as you save changes (called Hot Module Replacement).
This lets you focus on learning React itself rather than wrestling with build tool configuration.
Your development environment is ready. Next up, you'll start writing your first React components and see them appear in the browser instantly.
Build Your First Components
Your dev server is running and you can see the default Vite + React starter page. The next challenge is replacing that demo content with your own components.
In React, everything on screen is built from components. Each component is a reusable piece of UI that manages its own rendering. In this step, you will build two components: a card that displays a single country, and a grid that arranges multiple cards together.
In this step, get ready to:
- Clean up the scaffolded starter code.
- Build a reusable CountryCard component.
- Create a CountryGrid to display multiple cards.
Clean up the scaffolded files
The Vite React template ships with demo content: a counter component, CSS with Vite/React branding, and SVG logos. Clearing these out gives you a blank canvas for your own code.
- In Google Antigravity, open src/App.css by pressing Cmd+P (macOS) or Ctrl+P (Windows/Linux), typing App.css, and pressing Enter.
- Select all the content with Cmd+A (macOS) or Ctrl+A (Windows/Linux) and delete it so the file is completely empty.
- Save the file.
- Open src/index.css the same way and delete all its contents as well.
- Save index.css.
Now replace the demo App component with a minimal starting point.
- Open src/App.jsx and replace its entire contents with the following code:
import './App.css'
function App() {
return (
<div className="app">
<h1>Country Explorer</h1>
</div>
)
}
export default App
What does this code do?
This creates the simplest possible React component. function App() defines a component that returns JSX (HTML-like syntax that React uses to describe UI).
The export default App line makes this component available to main.jsx, which renders it into the browser.
- Save App.jsx.
- Switch to your browser. You should see a blank page with just the text "Country Explorer" as a heading.
What is hot module replacement?
Vite's dev server uses Hot Module Replacement (HMR) to push changes to your browser the moment you save a file. You never need to manually refresh the page during development.
Still seeing the Vite starter page?
Make sure you saved all three files (App.css, index.css, App.jsx). Unsaved changes won't trigger HMR.
Check your terminal for any red error messages. A typo in App.jsx (like a missing closing tag) will prevent the page from updating.
Help me debug why my browser still shows the starter page.
Create the CountryCard component
Each country in your app needs its own visual card. You will build a CountryCard component that receives country data as a prop and renders it into a styled card element.
- Create a components folder inside src by running this command in your terminal:
mkdir src/components
- Create a new file at src/components/CountryCard.jsx by running:
touch src/components/CountryCard.jsx
- Confirm the files exist by running:
ls src/components
You should see CountryCard.jsx in the output.
- Open src/components/CountryCard.jsx and paste the following code:
import styles from './CountryCard.module.css'
function CountryCard({ country }) {
// Handle countries with no capital (like Antarctica)
const capital = country.capital ? country.capital[0] : 'N/A'
// Format population with commas for readability
const population = country.population.toLocaleString()
return (
<div className={styles.card}>
<img
className={styles.flag}
src={country.flags.svg}
alt={`Flag of ${country.name.common}`}
/>
<div className={styles.info}>
<h2 className={styles.name}>{country.name.common}</h2>
<p><strong>Population:</strong> {population}</p>
<p><strong>Region:</strong> {country.region}</p>
<p><strong>Capital:</strong> {capital}</p>
</div>
</div>
)
}
export default CountryCard
What does this code do?
The { country } in the function signature is called destructuring. It pulls the country prop directly out of the props object so you can use it without writing props.country everywhere.
The component handles a real-world edge case: some countries (like Antarctica) have no capital city. The ternary country.capital ? country.capital[0] : 'N/A' gracefully falls back to "N/A" when the data is missing.
The toLocaleString() method formats population numbers with commas (e.g. 1,000,000) for readability.
- Save CountryCard.jsx.
- You won't see a change in the browser yet. The component needs styles and a parent component to render it.
✔️ Awesome, I've got everything!
Great. Double check you saved the file before moving on.
ⓧ I'd like to double check the full code
import styles from './CountryCard.module.css'
function CountryCard({ country }) {
const capital = country.capital ? country.capital[0] : 'N/A'
const population = country.population.toLocaleString()
return (
<div className={styles.card}>
<img
className={styles.flag}
src={country.flags.svg}
alt={`Flag of ${country.name.common}`}
/>
<div className={styles.info}>
<h2 className={styles.name}>{country.name.common}</h2>
<p><strong>Population:</strong> {population}</p>
<p><strong>Region:</strong> {country.region}</p>
<p><strong>Capital:</strong> {capital}</p>
</div>
</div>
)
}
export default CountryCard
Now add the styles that give the card its visual structure. CSS Modules scope styles to a single component, so class names never accidentally conflict across your app.
- Create src/components/CountryCard.module.css and paste the following styles:
.card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.flag {
width: 100%;
height: 160px;
object-fit: cover;
}
.info {
padding: 1rem;
}
.name {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #1a1a2e;
}
.info p {
font-size: 0.9rem;
margin-bottom: 0.25rem;
color: #444;
}
What does this code do?
The .card class creates a white rounded box with a subtle shadow. The :hover rule lifts the card slightly when the user mouses over it.
The .flag class uses object-fit: cover to ensure flag images fill the space without stretching, regardless of the flag's original aspect ratio.
- Save CountryCard.module.css.
- You still won't see a change yet. The next chunk creates the grid that renders these cards.
✔️ Awesome, I've got everything!
Great. Double check you saved the file before moving on.
ⓧ I'd like to double check the full code
.card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.flag {
width: 100%;
height: 160px;
object-fit: cover;
}
.info {
padding: 1rem;
}
.name {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #1a1a2e;
}
.info p {
font-size: 0.9rem;
margin-bottom: 0.25rem;
color: #444;
}
Why CSS Modules instead of Tailwind?
CSS Modules teach you how CSS scoping works at a fundamental level. Mid-level roles expect you to understand CSS itself, not just utility classes.
Vite supports CSS Modules out of the box with zero configuration. Any file ending in .module.css is automatically scoped to the component that imports it. No extra packages or setup required.
Create the CountryGrid component
A single card is useful, but you need a layout component to arrange many cards into a responsive grid. CountryGrid will receive an array of countries and map over it to render one CountryCard per country.
- Create src/components/CountryGrid.jsx and paste the following code:
import CountryCard from './CountryCard'
import styles from './CountryGrid.module.css'
function CountryGrid({ countries }) {
return (
<div className={styles.grid}>
{countries.map((country) => (
<CountryCard key={country.name.common} country={country} />
))}
</div>
)
}
export default CountryGrid
What does this code do?
The countries.map() call loops over the array and returns a CountryCard for each item. This is how React renders lists of data.
The key={country.name.common} prop gives React a unique identifier for each card. React uses keys to efficiently update only the items that changed, rather than re-rendering the entire list.
- Save CountryGrid.jsx.
- You won't see a change yet. The next chunk adds the grid layout styles.
✔️ Awesome, I've got everything!
Great. Double check you saved the file before moving on.
ⓧ I'd like to double check the full code
import CountryCard from './CountryCard'
import styles from './CountryGrid.module.css'
function CountryGrid({ countries }) {
return (
<div className={styles.grid}>
{countries.map((country) => (
<CountryCard key={country.name.common} country={country} />
))}
</div>
)
}
export default CountryGrid
- Create src/components/CountryGrid.module.css and paste the following styles:
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 2rem;
}
What does this code do?
The grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)) rule creates a responsive grid that automatically adjusts the number of columns based on available space. Each column is at least 250px wide and expands to fill remaining room.
- Save CountryGrid.module.css.
- The grid still won't appear until you pass data to it from App.jsx.
✔️ Awesome, I've got everything!
Great. Double check you saved the file before moving on.
ⓧ I'd like to double check the full code
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 2rem;
}
Now wire everything together by updating App.jsx to import CountryGrid and pass it some test data.
- Open src/App.jsx and replace its entire contents with the following code:
import CountryGrid from './components/CountryGrid'
import './App.css'
// Hardcoded test data matching the shape of the REST Countries API
const testCountries = [
{ name: { common: 'Test Country 1' }, population: 1000000, region: 'Europe', capital: ['Capital City'], flags: { svg: 'https://flagcdn.com/us.svg' } },
{ name: { common: 'Test Country 2' }, population: 2000000, region: 'Asia', capital: ['Another City'], flags: { svg: 'https://flagcdn.com/gb.svg' } },
]
function App() {
return (
<div className="app">
<h1>Country Explorer</h1>
<CountryGrid countries={testCountries} />
</div>
)
}
export default App
What does this code do?
The testCountries array contains two fake country objects that match the shape of real REST Countries API data. This lets you verify your components render correctly before connecting to the live API.
The <CountryGrid countries={testCountries} /> line passes the array down as a prop. CountryGrid maps over it, and each item flows into a CountryCard. This is the core React data flow: parent passes props, children render.
- Save App.jsx.
- Switch to your browser. You should see a "Country Explorer" heading with two country cards below it. Each card shows a flag image, country name, population, region, and capital.
Don't see the cards?
Check your terminal for error messages. A common issue is a typo in the import path. Make sure './components/CountryGrid' matches your folder name exactly (case-sensitive).
If you see a blank page, verify that CountryCard.jsx and CountryCard.module.css are both saved inside src/components/.
Help me debug why my country cards aren't showing up.
✔️ Awesome, I've got everything!
Great. Double check you saved all your files before moving on.
ⓧ I'd like to double check the full code
import CountryGrid from './components/CountryGrid'
import './App.css'
const testCountries = [
{ name: { common: 'Test Country 1' }, population: 1000000, region: 'Europe', capital: ['Capital City'], flags: { svg: 'https://flagcdn.com/us.svg' } },
{ name: { common: 'Test Country 2' }, population: 2000000, region: 'Asia', capital: ['Another City'], flags: { svg: 'https://flagcdn.com/gb.svg' } },
]
function App() {
return (
<div className="app">
<h1>Country Explorer</h1>
<CountryGrid countries={testCountries} />
</div>
)
}
export default App
Your component architecture is taking shape. You have a reusable CountryCard, a layout grid, and test data proving everything connects. Next up, you will replace that hardcoded data with a live API call using a custom React hook.
Fetch Real Data with a Custom Hook
Your components render nicely with hardcoded test data. The real power of a React app comes when it connects to live data sources.
In this step, you'll build a custom hook that fetches real country data from a public API. Custom hooks let you package complex logic (like fetching, loading states, and error handling) into a single reusable function that any component can call.
In this step, get ready to:
- Create a custom useCountries hook that fetches country data from the REST Countries API.
- Update App.jsx to use the hook with conditional rendering for loading, error, and data states.
- Verify that population formatting and missing capital handling work with real API data.
Create the useCountries custom hook
A custom hook is a JavaScript function whose name starts with use. It lets you extract stateful logic out of a component so multiple components can reuse it without duplicating code.
- In the Explorer sidebar, right-click the src folder and select New Folder.
- Name the folder hooks.
- Right-click the new hooks folder and select New File.
- Name the file useCountries.js.
- Add the following code to useCountries.js:
import { useState, useEffect } from 'react'
function useCountries() {
const [countries, setCountries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchCountries() {
try {
const response = await fetch(
'https://restcountries.com/v3.1/all?fields=name,population,region,capital,flags,languages'
)
if (!response.ok) {
throw new Error('Failed to fetch countries')
}
const data = await response.json()
setCountries(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchCountries()
}, [])
return { countries, loading, error }
}
export default useCountries
What does this code do?
useState creates three pieces of state: countries (starts as an empty array), loading (starts as true), and error (starts as null).
useEffect with an empty [] dependency array runs the fetch exactly once when the component mounts. The try/catch/finally pattern ensures that loading is set to false whether the fetch succeeds or fails.
The hook returns an object with all three state values. Any component that calls useCountries() gets access to the data, loading state, and any error.
Why a custom hook instead of fetching directly in App?
Custom hooks are the pattern that separates beginners from mid-level React developers. They keep your components focused on rendering while the hook handles complex logic.
If you later need country data in a different component, you can call useCountries() again without rewriting the fetch logic.
- Save src/hooks/useCountries.js.
Seeing a red underline on the import?
Make sure the file is saved inside src/hooks/ and not directly in src/. The folder name must be exactly hooks (lowercase, no typos).
help me fix my useCountries hook file location
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react'
function useCountries() {
const [countries, setCountries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchCountries() {
try {
const response = await fetch(
'https://restcountries.com/v3.1/all?fields=name,population,region,capital,flags,languages'
)
if (!response.ok) {
throw new Error('Failed to fetch countries')
}
const data = await response.json()
setCountries(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchCountries()
}, [])
return { countries, loading, error }
}
export default useCountries
Use the hook in App.jsx
Now you'll replace the hardcoded test data with real data from your custom hook. You'll also add conditional rendering so the app shows appropriate feedback while loading or if something goes wrong.
- Switch back to src/App.jsx.
- Replace the entire file contents with the following code:
import useCountries from './hooks/useCountries'
import CountryGrid from './components/CountryGrid'
import './App.css'
function App() {
// Destructure the hook's return value into three variables
const { countries, loading, error } = useCountries()
return (
<div className="app">
<h1>Country Explorer</h1>
{loading && <p className="status">Loading countries...</p>}
{error && <p className="status error">Error: {error}</p>}
{!loading && !error && <CountryGrid countries={countries} />}
</div>
)
}
export default App
What does this code do?
- useCountries() calls the custom hook you just built. It returns countries, loading, and error as a destructured object.
- {loading && <p>...</p>} is conditional rendering. React only renders the paragraph when loading is true.
- The pattern {!loading && !error && <CountryGrid />} ensures the grid only appears when data has loaded successfully.
- Save src/App.jsx.
- Return to your browser at http://localhost:5173. You should briefly see "Loading countries..." and then a grid filled with real country cards showing flags, names, populations, regions, and capitals.
Still seeing hardcoded test data?
Make sure you deleted the entire testCountries array and replaced all of App.jsx with the new code. Check that the import path './hooks/useCountries' matches the folder and file you created.
help me debug why my useCountries hook data isn't showing
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import useCountries from './hooks/useCountries'
import CountryGrid from './components/CountryGrid'
import './App.css'
function App() {
const { countries, loading, error } = useCountries()
return (
<div className="app">
<h1>Country Explorer</h1>
{loading && <p className="status">Loading countries...</p>}
{error && <p className="status error">Error: {error}</p>}
{!loading && !error && <CountryGrid countries={countries} />}
</div>
)
}
export default App
Verify data formatting with real API data
Your CountryCard component already handles two important formatting tasks from the previous step. Now that real data is flowing through, you can see them in action.
- Look at the population numbers on your country cards. They display with comma separators (e.g. 67,390,000 for France) thanks to toLocaleString() in CountryCard.jsx.
- Scroll through the grid and look for countries that show "N/A" as their capital. Antarctica is one example. Your ternary expression country.capital ? country.capital[0] : 'N/A' handles this gracefully because some entries in the API have no capital field.
Why do some countries have no capital?
The REST Countries API includes territories and special entries like Antarctica, which has no government or capital city. Without the ternary check, your app would crash with a "Cannot read properties of undefined" error when trying to access country.capital[0] on those entries.
Your app is now powered by live data from 250 countries around the world. Next up, you'll add search and filter functionality so users can find exactly the country they're looking for.
Add Search and Filter Functionality
Your app now displays over 250 countries from the REST Countries API. That's impressive, but scrolling through all of them to find a specific country is not a great user experience.
In this step, you'll build two interactive components that let users narrow down the list. A search bar filters countries by name as the user types. A dropdown filters by region (Africa, Americas, Asia, Europe, Oceania).
In this step, get ready to:
- Create a SearchBar component that filters countries by name in real-time.
- Create a FilterDropdown component that filters countries by region.
- Wire up filtering logic in App.jsx using derived state.
Create the SearchBar component
The SearchBar is a controlled input that notifies its parent component every time the user types. It doesn't manage the search logic itself. Instead, it calls a callback function (passed as a prop) with the current input value.
- Create a new file src/components/SearchBar.jsx and add the following code:
import styles from './SearchBar.module.css'
function SearchBar({ onSearch }) {
return (
<input
className={styles.input}
type="text"
placeholder="Search for a country..."
onChange={(e) => onSearch(e.target.value)}
/>
)
}
export default SearchBar
What does this code do?
This component receives an onSearch function as a prop. Every time the user types in the input, the onChange event fires and passes the current value to onSearch.
The SearchBar doesn't know what happens with the search term. It just reports it upward. This makes the component reusable in different contexts.
- Save SearchBar.jsx.
- You won't see a change in the browser yet. The SearchBar won't render until we wire it into App.jsx in the final substep.
- Now create the stylesheet src/components/SearchBar.module.css and add the following styles:
.input {
flex: 1;
min-width: 200px;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 1rem;
outline: none;
}
.input:focus {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
What do these styles do?
The flex: 1 makes the input expand to fill available space in the toolbar row. The :focus pseudo-class deepens the shadow when the user clicks into the input, giving a subtle interaction cue.
- Save SearchBar.module.css.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here's src/components/SearchBar.jsx:
import styles from './SearchBar.module.css'
function SearchBar({ onSearch }) {
return (
<input
className={styles.input}
type="text"
placeholder="Search for a country..."
onChange={(e) => onSearch(e.target.value)}
/>
)
}
export default SearchBar
And src/components/SearchBar.module.css:
.input {
flex: 1;
min-width: 200px;
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 1rem;
outline: none;
}
.input:focus {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
Create the FilterDropdown component
The FilterDropdown works the same way as SearchBar. It renders a <select> element with region options and calls a callback prop whenever the selection changes.
- Create a new file src/components/FilterDropdown.jsx and add the following code:
import styles from './FilterDropdown.module.css'
function FilterDropdown({ onFilter }) {
return (
<select
className={styles.select}
onChange={(e) => onFilter(e.target.value)}
defaultValue="All"
>
<option value="All">Filter by Region</option>
<option value="Africa">Africa</option>
<option value="Americas">Americas</option>
<option value="Asia">Asia</option>
<option value="Europe">Europe</option>
<option value="Oceania">Oceania</option>
</select>
)
}
export default FilterDropdown
What does this code do?
The defaultValue="All" sets the initial selection to the "Filter by Region" option, which shows all countries. Each <option> has a value that matches the exact region strings returned by the REST Countries API.
When the user picks a region, onChange fires and passes that region string to the onFilter callback.
- Save FilterDropdown.jsx.
- You won't see a change yet. The next chunk styles this component.
- Create the stylesheet src/components/FilterDropdown.module.css and add these styles:
.select {
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 1rem;
background: white;
cursor: pointer;
outline: none;
min-width: 180px;
}
.select:focus {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
What do these styles do?
The min-width: 180px keeps the dropdown from collapsing too narrow on small screens. The cursor: pointer gives users a visual hint that the element is clickable.
- Save FilterDropdown.module.css.
✔️ Awesome, I've got everything!
Great. Make sure both FilterDropdown files are saved before moving on.
ⓧ I'd like to double check the full code
Here's src/components/FilterDropdown.jsx:
import styles from './FilterDropdown.module.css'
function FilterDropdown({ onFilter }) {
return (
<select
className={styles.select}
onChange={(e) => onFilter(e.target.value)}
defaultValue="All"
>
<option value="All">Filter by Region</option>
<option value="Africa">Africa</option>
<option value="Americas">Americas</option>
<option value="Asia">Asia</option>
<option value="Europe">Europe</option>
<option value="Oceania">Oceania</option>
</select>
)
}
export default FilterDropdown
And src/components/FilterDropdown.module.css:
.select {
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 1rem;
background: white;
cursor: pointer;
outline: none;
min-width: 180px;
}
.select:focus {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
Wire up the filtering logic in App.jsx
Both components are ready, but they don't appear on the page yet. You need to import them into App.jsx, add state to track the user's inputs, and create a filtered list that updates automatically.
- Replace the entire contents of src/App.jsx with the following code:
import { useState } from 'react'
import useCountries from './hooks/useCountries'
import CountryGrid from './components/CountryGrid'
import SearchBar from './components/SearchBar'
import FilterDropdown from './components/FilterDropdown'
import './App.css'
function App() {
const { countries, loading, error } = useCountries()
// Track the current search text and selected region
const [searchTerm, setSearchTerm] = useState('')
const [selectedRegion, setSelectedRegion] = useState('All')
// Derive a filtered list from the full countries array
const filteredCountries = countries
.filter((country) =>
country.name.common.toLowerCase().includes(searchTerm.toLowerCase())
)
.filter((country) =>
selectedRegion === 'All' ? true : country.region === selectedRegion
)
return (
<div className="app">
<h1>Country Explorer</h1>
<div className="toolbar">
<SearchBar onSearch={setSearchTerm} />
<FilterDropdown onFilter={setSelectedRegion} />
</div>
{loading && <p className="status">Loading countries...</p>}
{error && <p className="status error">Error: {error}</p>}
{!loading && !error && <CountryGrid countries={filteredCountries} />}
</div>
)
}
export default App
What does this code do?
Two new useState calls track the search term and selected region. These update instantly as the user types or picks a region.
The filteredCountries variable chains two .filter() calls. The first checks if the country name includes the search term (case-insensitive). The second checks if the country's region matches the selected region, skipping the check entirely when "All" is selected.
Because filteredCountries is derived from state, React automatically re-renders the grid whenever the user types or changes the dropdown. No extra useEffect needed.
- Save App.jsx.
- Switch to your browser at http://localhost:5173. You should see a search input and a region dropdown above the country grid.
- Type japan in the search bar. The grid should filter down to show only Japan.
- Clear the search and select Europe from the dropdown. Only European countries should appear.
- Try combining both: type ger with Europe selected. You should see Germany.
Search or filter not working?
- Make sure you saved all four new files (SearchBar.jsx, SearchBar.module.css, FilterDropdown.jsx, FilterDropdown.module.css) plus the updated App.jsx.
- Check your terminal for import errors. If a filename is misspelled in the import path, Vite shows a red error overlay.
- Confirm the import { useState } from 'react' line is at the top of App.jsx. Without it, the useState calls will throw a reference error.
Still stuck? Help me debug why my search or filter isn't working in my React Country Explorer app.
Why derived state instead of useEffect?
You might expect to use useEffect to update a separate "filtered" state whenever the search term changes. But that creates unnecessary complexity. A variable derived directly from existing state (like filteredCountries) recalculates on every render automatically.
This pattern is called derived state. It keeps your component simpler and avoids bugs where the filtered list gets out of sync with the search term.
✔️ Awesome, I've got everything!
Great. Double check you've saved App.jsx and confirmed the search and filter work in the browser.
ⓧ I'd like to double check the full code
Here's the complete src/App.jsx:
import { useState } from 'react'
import useCountries from './hooks/useCountries'
import CountryGrid from './components/CountryGrid'
import SearchBar from './components/SearchBar'
import FilterDropdown from './components/FilterDropdown'
import './App.css'
function App() {
const { countries, loading, error } = useCountries()
const [searchTerm, setSearchTerm] = useState('')
const [selectedRegion, setSelectedRegion] = useState('All')
const filteredCountries = countries
.filter((country) =>
country.name.common.toLowerCase().includes(searchTerm.toLowerCase())
)
.filter((country) =>
selectedRegion === 'All' ? true : country.region === selectedRegion
)
return (
<div className="app">
<h1>Country Explorer</h1>
<div className="toolbar">
<SearchBar onSearch={setSearchTerm} />
<FilterDropdown onFilter={setSelectedRegion} />
</div>
{loading && <p className="status">Loading countries...</p>}
{error && <p className="status error">Error: {error}</p>}
{!loading && !error && <CountryGrid countries={filteredCountries} />}
</div>
)
}
export default App
Your app now lets users search and filter through 250+ countries instantly. Next up, you'll polish the UI and handle edge cases like empty search results and loading states.
Polish the UI and Handle Edge Cases
Your search and filter logic works, but the app still looks unstyled. Raw HTML without spacing, fonts, or layout creates a poor user experience.
In this step, you'll add global styles and app-level layout, handle the empty-results edge case, and test the full app end-to-end.
In this step, get ready to:
- Add global reset and body styles for consistent rendering.
- Style the app container, heading, toolbar, and status messages.
- Handle empty results and test search plus filter together.
Add global styles for the overall layout
Browsers apply default margin and padding to elements, which causes inconsistent spacing across different browsers. A CSS reset removes those defaults so your layout starts from a clean slate.
- Open src/index.css in the editor and paste the following styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f0f2f5;
color: #1a1a2e;
line-height: 1.6;
}
What does this code do?
The * selector targets every element on the page. Setting margin: 0 and padding: 0 strips browser defaults so nothing surprises you.
The box-sizing: border-box rule makes padding and borders count inside an element's width instead of adding to it. The body styles set a system font stack, a light gray background, and comfortable line spacing.
- Save src/index.css.
- Switch to your browser at http://localhost:5173. You should see the page background change to a light gray (#f0f2f5) and the text spacing tighten up as the browser defaults disappear.
Background still white?
- Make sure you saved src/index.css (not src/App.css).
- Confirm src/main.jsx still contains import './index.css'. Without that import, Vite won't include the global stylesheet.
Why isn't my index.css being applied?
Now you'll add styles specific to the App component. These control the page container, heading, toolbar layout, and status messages.
- Open src/App.css and paste the following styles:
/* Centers the app content with a max width */
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Style the page heading */
.app h1 {
text-align: center;
margin-bottom: 1.5rem;
color: #1a1a2e;
font-size: 2rem;
}
/* Arrange search bar and dropdown in a row */
.toolbar {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
/* Loading, error, and empty-state messages */
.status {
text-align: center;
font-size: 1.1rem;
color: #555;
margin-top: 2rem;
}
.status.error {
color: #e63946;
}
What does this code do?
- .app constrains the page to 1200px wide and centers it horizontally with margin: 0 auto.
- .toolbar uses flexbox to place the search bar and dropdown side by side. The flex-wrap: wrap rule lets them stack on narrow screens.
- .status and .status.error style the loading, error, and empty-state messages. Errors display in red to stand out.
- Save src/App.css and refresh your browser.
- You should see the heading centered at the top, the search bar and dropdown sitting in a neat row below it, and the country cards in a centered grid.
Toolbar not in a row?
- Check that your App.jsx wraps the SearchBar and FilterDropdown in a <div className="toolbar">. The class name must match the CSS selector exactly.
- Make sure the import './App.css' line exists at the top of App.jsx.
Help me debug my toolbar layout
Handle the empty-results edge case
Right now, if a search matches zero countries, the grid just disappears with no feedback. Users need a clear message telling them nothing matched their query.
- In src/App.jsx, find this line near the bottom of the return statement:
{!loading && !error && <CountryGrid countries={filteredCountries} />}
- Replace that single line with these two conditional blocks:
{!loading && !error && filteredCountries.length === 0 && (
<p className="status">No countries found.</p>
)}
{!loading && !error && filteredCountries.length > 0 && (
<CountryGrid countries={filteredCountries} />
)}
What does this code do?
Instead of always rendering the grid, the code now checks filteredCountries.length first. If the array is empty, it shows a "No countries found." message. If there are results, it renders the grid as before.
Both conditions also check !loading && !error so the empty-state message only appears after the data has finished loading successfully.
- Save src/App.jsx.
- In your browser, type xyzxyz into the search bar. You should see "No countries found." appear in gray centered text below the toolbar.
Still seeing the grid with no message?
- Make sure you removed the original {!loading && !error && <CountryGrid ... />} line. If both the old and new code exist, the grid renders regardless of length.
- Check that filteredCountries.length === 0 uses triple equals, not double. Triple equals prevents type coercion bugs.
Help me fix my empty-state rendering
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full src/index.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f0f2f5;
color: #1a1a2e;
line-height: 1.6;
}
Here is the full src/App.css:
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app h1 {
text-align: center;
margin-bottom: 1.5rem;
color: #1a1a2e;
font-size: 2rem;
}
.toolbar {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.status {
text-align: center;
font-size: 1.1rem;
color: #555;
margin-top: 2rem;
}
.status.error {
color: #e63946;
}
Here is the full src/App.jsx:
import { useState } from 'react'
import useCountries from './hooks/useCountries'
import CountryGrid from './components/CountryGrid'
import SearchBar from './components/SearchBar'
import FilterDropdown from './components/FilterDropdown'
import './App.css'
function App() {
const { countries, loading, error } = useCountries()
const [searchTerm, setSearchTerm] = useState('')
const [selectedRegion, setSelectedRegion] = useState('All')
const filteredCountries = countries
.filter((country) =>
country.name.common.toLowerCase().includes(searchTerm.toLowerCase())
)
.filter((country) =>
selectedRegion === 'All' ? true : country.region === selectedRegion
)
return (
<div className="app">
<h1>Country Explorer</h1>
<div className="toolbar">
<SearchBar onSearch={setSearchTerm} />
<FilterDropdown onFilter={setSelectedRegion} />
</div>
{loading && <p className="status">Loading countries...</p>}
{error && <p className="status error">Error: {error}</p>}
{!loading && !error && filteredCountries.length === 0 && (
<p className="status">No countries found.</p>
)}
{!loading && !error && filteredCountries.length > 0 && (
<CountryGrid countries={filteredCountries} />
)}
</div>
)
}
export default App
Test the complete app
With styling and edge cases handled, it is time to verify that search and filter work together correctly.
- Clear the search bar so all countries appear in the grid.
- Type japan into the search bar. You should see only Japan's card with its flag, population, region (Asia), and capital (Tokyo).
- Clear the search bar again, then select Europe from the region dropdown. The grid should show only European countries.
- With Europe still selected, type ger into the search bar. You should see Germany's card as the only result.
Why test search and filter together?
Each filter works on its own, but combining them exercises the chained .filter() logic in your filteredCountries variable. If either filter has a bug, the combined test surfaces it immediately.
Your Country Explorer is fully functional. You have a polished UI, real data from a live API, working search and filter, and proper handling of loading, error, and empty states. Next up is the Secret Mission, where you can add a favorites feature with localStorage persistence.
Secret mission
Add Favorites with localStorage
Your Country Explorer works great for browsing, but what if users want to save their favorite countries? Build a favorites feature that persists across page refreshes using localStorage and a custom hook.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The country-explorer project folder on your Desktop (all source code and dependencies).
- The Vite dev server running in your terminal.
- Browser localStorage data storing your favorites list.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep exploring the app.
- Your dev server stays running at http://localhost:5173 until you close the terminal.
- Your country-explorer folder remains on your Desktop.
- Node.js and Google Antigravity IDE remain installed on your machine for future projects.
Pause - I'll come back to this later
Shut down running processes to free up your terminal. Your project files stay intact for when you return.
- In the terminal where your dev server is running, press Ctrl+C to stop the Vite dev server.
- Your country-explorer folder stays on your Desktop. When you're ready to resume, open the folder in Google Antigravity and run npm run dev again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- In the terminal where your dev server is running, press Ctrl+C to stop the server.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/country-explorer
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\country-explorer
- Clear the favorites data from your browser. Open your browser's Developer Tools, go to the Application tab, expand Local Storage, click on http://localhost:5173, and delete the favorites entry.
Nice Work!
Nice Work!
You just built a real React application from scratch. Your Country Explorer fetches live data from an API, renders it in reusable components, and lets users search, filter, and favorite countries with instant feedback.
You've learned how to:
- Build reusable components with props and compose them into a clean architecture (CountryCard, CountryGrid, SearchBar, FilterDropdown).
- Create a custom hook (useCountries) that encapsulates API data fetching with loading and error states using useState and useEffect.
- Implement real-time search and filtering using derived state, combining text input and a region dropdown to filter 250+ countries instantly.
- Added a favorites system with localStorage persistence using a custom useFavorites hook.
Ready to quiz yourself?