Build a Post Explorer with React
Fetch, display, and filter live API data using React's core hooks.
Introduction
30 Second Summary
Think about the apps you use every day. Whether it's scrolling through social media, browsing a news feed, or searching a product catalog, they all do the same thing: pull data from the internet and display it in a way that's easy to explore.
In this project, you will build a Post Explorer app with React that fetches blog posts from a live API and displays them as a searchable card grid. You will learn how to handle loading states, errors, and real-time search filtering.
What You'll Build
You type a word into the search bar, the grid of 100 blog post cards filters instantly, and a counter at the top ticks down to show exactly how many posts match.
By the end of this project, you'll have:
- A real-time search bar that filters 100 blog posts by title as you type, with a count of matching results and a message when nothing matches.
- A loading indicator that keeps users informed while data arrives from the JSONPlaceholder API.
- Clear error handling that displays a helpful message when the network request fails, instead of silently breaking.
- Secret Mission: Add user details to each post by fetching and merging data from a second API endpoint.
Are there any prerequisites?
None. This guide includes full setup instructions for every tool you need, including Node.js and VS Code.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Scaffold Your React Project
Before you can build anything, you need a working development environment. React apps need a build tool to bundle your code and serve it in the browser.
In this step, you'll scaffold a brand-new React project using Vite, the build tool that React's own documentation recommends. Vite gives you instant hot-reload, so every change you make shows up in the browser immediately.
In this step, get ready to:
- Verify that Node.js is installed on your machine.
- Scaffold a new React project with Vite.
- Run the development server and see the starter page in your browser.
Check your Node.js version
Vite and React both run on Node.js, a JavaScript runtime that lets you execute code outside the browser. Vite 8 requires Node.js version 20.19 or higher.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Node.js version by running this command:
node --version
Look at the version number that appears in your terminal.
✔️ I see version 20.19 or higher
Your Node.js version meets Vite's requirement. You're ready to scaffold your project.
ⓧ I see a lower version
Your Node.js version is too old for Vite 8. You need to upgrade to the latest LTS version.
- Go to the Node.js download page.
- Download the LTS version for your operating system.
- Run the installer and follow the prompts to complete the upgrade.
- Close your terminal completely, reopen it, then run node --version again to confirm the new version.
ⓧ Command not found
Node.js is not installed on your machine yet. Let's install it now.
- Go to the Node.js download page.
- Download the LTS version for your operating system.
- Run the installer and follow the prompts to complete the installation.
- Close your terminal completely, reopen it, then run node --version to confirm Node.js is installed.
Scaffold your React project with Vite
Now that Node.js is ready, you can use Vite's scaffolding command to generate a complete React project in one shot.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new React project called post-explorer by running this command:
npm create vite@latest post-explorer -- --template react
What does this command do?
- npm create vite@latest downloads and runs Vite's project generator.
- post-explorer is the name of your project folder.
- --template react tells Vite to set up a React project with JavaScript (not TypeScript).
- Confirm the project folder was created by running:
ls post-explorer
You should see files like index.html, package.json, vite.config.js, and a src folder listed in the output.
Don't see the project folder?
- Make sure you ran the cd ~/Desktop command first. Without it, the project may have been created in a different folder.
- Check that the command completed without errors. If you see a network error, check your internet connection and try again.
Need more help? Help me troubleshoot why my Vite scaffold command didn't create a project folder.
Install dependencies and start the dev server
The scaffold created your project files, but the packages React depends on haven't been downloaded yet. Let's install them and start the development server.
- Move into your project folder and install the dependencies by running these commands:
cd post-explorer
npm install
You should see npm downloading packages, followed by a summary of the installed dependencies.
Seeing npm errors during install?
- Make sure you are inside the post-explorer folder. Run pwd (macOS) or cd (Windows) to check your current directory.
- If you see permission errors, try closing and reopening your terminal, then running npm install again.
Still stuck? Help me debug my npm install error in my Vite React project.
- Start the development server by running:
npm run dev
What does npm run dev do?
This starts Vite's development server, which watches your files for changes and instantly refreshes the browser. The terminal stays busy while the server runs.
Don't close this terminal window. Leave it running in the background for the rest of the project.
Your terminal now shows a local URL where your app is running. Before you open it, what do you think you'll see on the page?
- Open your browser and go to http://localhost:5173.
You should see the Vite + React starter page. It has the Vite and React logos at the top and a count is 0 button you can click to increment.
Don't see the starter page?
- Check that the terminal where you ran npm run dev is still running. If you accidentally closed it, reopen it, navigate back to the post-explorer folder, and run npm run dev again.
- Make sure the URL in your browser is exactly http://localhost:5173. If port 5173 was taken, Vite may have chosen a different port shown in the terminal output.
Still stuck? Help me get the Vite dev server running so I can see the starter page.
Your development environment is ready and running. Next up, you'll replace the starter page with your own Post Explorer layout and build your first React component.
Display a Hardcoded List of Posts
Your React project is scaffolded and the dev server is running. The browser still shows Vite's default starter page, but you need it to display post cards instead.
Before connecting to a live API, you will build the component structure with hardcoded data. This lets you see the layout take shape and catch structural issues early.
In this step, get ready to:
- Create a reusable PostCard component that renders a single post.
- Build the main App component with a hardcoded list of three posts.
- Style the app layout and post cards with custom CSS.
Create the PostCard component
React apps are built from components. Each component is a function that returns a chunk of UI. You will create a PostCard component that knows how to display a single post's title and body text.
- In the Explorer sidebar in VS Code, right-click the src folder.
- Select New File and name it PostCard.jsx.
- Paste the following code into PostCard.jsx:
// Receives title and body as props from the parent component
function PostCard({ title, body }) {
return (
<div className="post-card">
<h2 className="post-title">{title}</h2>
<p className="post-body">{body}</p>
</div>
)
}
export default PostCard
What does this code do?
This component receives data through props. Props are the way a parent component passes data down to a child.
- { title, body } destructures the props object so you can use each value directly.
- export default PostCard makes the component available for other files to import.
- Save PostCard.jsx.
- Confirm PostCard.jsx appears inside the src folder in the Explorer sidebar.
✔️ Awesome, I've got everything!
Great. Nothing changes in the browser yet because no file imports PostCard. You will wire it up next.
ⓧ I'd like to double check the full code
// Receives title and body as props from the parent component
function PostCard({ title, body }) {
return (
<div className="post-card">
<h2 className="post-title">{title}</h2>
<p className="post-body">{body}</p>
</div>
)
}
export default PostCard
Replace App.jsx with hardcoded posts
The starter App.jsx is full of demo code you do not need. You will replace it with a component that holds a hardcoded array of posts and renders one PostCard for each item.
- Open src/App.jsx in VS Code.
- Select all the existing code and delete it.
- Paste in the following code:
import PostCard from './PostCard'
import './App.css'
// Fake data so you can see the layout before connecting to a real API
const hardcodedPosts = [
{ id: 1, title: 'First Post Title', body: 'This is the body of the first post.' },
{ id: 2, title: 'Second Post Title', body: 'This is the body of the second post.' },
{ id: 3, title: 'Third Post Title', body: 'This is the body of the third post.' },
]
function App() {
return (
<div className="app">
<h1>Post Explorer</h1>
{/* Loop through each post and render a PostCard */}
<div className="post-grid">
{hardcodedPosts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
</div>
)
}
export default App
What does this code do?
- hardcodedPosts is an array of three post objects, each with an id, title, and body. This stands in for real API data.
- .map() loops through each post and returns a PostCard component for it. React uses this pattern to render lists of items.
- key={post.id} gives each card a unique identifier so React can efficiently update only the items that change.
- Save App.jsx.
- Switch to your browser. You should see the heading "Post Explorer" and three blocks of text for your posts.
The posts look plain right now. That is expected. You have not added any custom styles yet.
Don't see the posts?
- Check that you saved both PostCard.jsx and App.jsx. Unsaved files show a dot next to their name in the VS Code tab bar.
- Make sure PostCard.jsx is inside the src folder, not the project root. The import path './PostCard' looks in the same folder as App.jsx.
- If the browser shows a blank page, open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for a red error message pointing to the issue.
Still stuck? Help me debug why my posts aren't showing up in the browser.
✔️ Awesome, I've got everything!
Double check you have saved App.jsx. On to the styles.
ⓧ I'd like to double check the full code
import PostCard from './PostCard'
import './App.css'
const hardcodedPosts = [
{ id: 1, title: 'First Post Title', body: 'This is the body of the first post.' },
{ id: 2, title: 'Second Post Title', body: 'This is the body of the second post.' },
{ id: 3, title: 'Third Post Title', body: 'This is the body of the third post.' },
]
function App() {
return (
<div className="app">
<h1>Post Explorer</h1>
<div className="post-grid">
{hardcodedPosts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
</div>
)
}
export default App
Style the app layout and post cards
The posts show up, but they look like raw text. Custom CSS will turn them into a clean, centered card grid.
- Open src/App.css in VS Code.
- Select all the existing code and delete it.
- Paste in the following layout styles:
/* Center the app and set a comfortable max width */
.app {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
/* Style the page heading */
.app h1 {
text-align: center;
margin-bottom: 1.5rem;
color: #1a1a2e;
}
/* Arrange post cards in a responsive grid */
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
What do these styles do?
- .app caps the content at 900px and centers it horizontally with margin: 0 auto.
- .post-grid uses CSS Grid to create a responsive layout. Cards are at least 280px wide and the grid fills as many columns as the screen allows.
- Save App.css and switch to your browser.
- The heading should now be centered, and the posts should sit in a grid layout instead of stacking in a single column.
The cards still look like plain text. The next chunk adds borders, padding, and hover effects to make them look like proper cards.
- Return to src/App.css and paste the following styles below the code you just added:
/* Card container with a border and rounded corners */
.post-card {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 1.5rem;
transition: box-shadow 0.2s;
}
/* Lift the card on hover */
.post-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Style the post title */
.post-title {
font-size: 1rem;
color: #1a1a2e;
margin: 0 0 0.75rem 0;
text-transform: capitalize;
}
/* Style the post body text */
.post-body {
font-size: 0.875rem;
color: #555;
line-height: 1.5;
margin: 0;
}
What do these styles do?
- .post-card gives each card a white background, a subtle border, and rounded corners.
- .post-card:hover adds a soft shadow when the learner hovers over a card, creating a "lift" effect.
- text-transform: capitalize on .post-title capitalizes the first letter of each word in the title.
Before you save, what do you think the cards will look like now? Picture rounded white boxes with a slight lift when you hover over them.
- Save App.css and switch to your browser.
- You should see three white cards with rounded borders, each showing a title and body text.
- Hover over a card. It should lift slightly with a shadow effect.
Cards still look unstyled?
- Make sure you deleted all the original content from App.css before pasting. Leftover styles from the Vite template can override your new ones.
- Check that the class names in your CSS match the ones in your JSX. For example, .post-card in CSS must match className="post-card" in PostCard.jsx.
Still stuck? Help me figure out why my post card styles aren't applying.
✔️ Awesome, I've got everything!
Double check you have saved App.css.
ⓧ I'd like to double check the full code
Your App.css should contain both chunks you pasted above, in order. Scroll up and confirm:
- The layout styles chunk (.app, .app h1, .post-grid) comes first.
- The card styles chunk (.post-card, .post-card:hover, .post-title, .post-body) comes second.
Your layout is working and styled with hardcoded data. Next up, you will replace those fake posts with live data from a real API.
Fetch Real Posts from the API
Your post cards look great, but the data behind them is hardcoded. Three fake posts baked directly into your code won't cut it for a real app.
In this step, you'll connect to a live API to fetch 100 real blog posts and display them on screen. You'll use two core React hooks to make this happen.
In this step, get ready to:
- Replace the hardcoded posts with live API data using useState and useEffect.
- Verify that the browser renders all 100 posts from the JSONPlaceholder API.
Replace the hardcoded posts with a live API call
React gives you two hooks for this job. useState holds data that can change over time, and useEffect runs code when a component first appears on screen.
- Replace the entire contents of src/App.jsx with this code:
import { useState, useEffect } from 'react'
import PostCard from './PostCard'
import './App.css'
function App() {
const [posts, setPosts] = useState([]) // Start with an empty array
useEffect(() => { // Runs once when the component first renders
fetch('https://jsonplaceholder.typicode.com/posts')
.then((response) => response.json())
.then((data) => {
console.log(data)
setPosts(data)
})
}, [])
return (
<div className="app">
<h1>Post Explorer</h1>
<div className="post-grid">
{posts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
</div>
)
}
export default App
What does this code do?
- useState([]) creates a posts variable that starts as an empty array. When you call setPosts(data), React re-renders the component with the new data.
- useEffect(() => { ... }, []) runs the fetch call once when the component first appears. The empty [] at the end tells React not to re-run this effect on future renders.
- fetch() sends a request to the JSONPlaceholder API, which returns 100 blog posts. The .then() chain parses the JSON response and stores it in state.
- console.log(data) prints the fetched posts to your browser console so you can confirm the data arrived.
What is JSONPlaceholder?
JSONPlaceholder is a free fake REST API that serves test data like posts, users, and comments. It requires no API key and no signup, so you can focus on learning React instead of setting up an API.
- Save src/App.jsx.
Before you look at the browser, what do you think will happen? You started with 3 hardcoded posts, and now the app fetches 100 from a live API. Will it look any different while the data loads?
- Switch to your browser. Watch closely as the page refreshes.
You should see a brief flash of completely empty content below the heading. Then 100 post cards appear all at once, filling the grid.
Why the flash of empty content?
Your posts state starts as an empty array. React renders the page immediately with that empty array, so there is nothing to show. A moment later, the API response arrives, setPosts(data) fires, and React re-renders with all 100 posts.
That blank flash is a bad user experience. A real app should show a loading indicator while data is on its way. You'll fix this in the next step.
Confirm the data in your console
The console.log(data) in your code writes the entire API response to the browser console. This is a quick way to prove the data arrived and inspect its shape.
- Right-click anywhere on the page and select Inspect.
- Click the Console tab at the top of the DevTools panel.
You should see an array of 100 objects logged in the console. Click the arrow next to the array to expand it and see each post's id, title, and body fields.
Not seeing 100 posts or the console log?
- Make sure you saved src/App.jsx. Unsaved changes won't trigger a browser refresh.
- Check that the API URL is exactly https://jsonplaceholder.typicode.com/posts. A typo in the URL will cause a silent failure since there is no error handling yet.
- Make sure you replaced the entire file, including the import line at the top. The old import does not include useState or useEffect.
Help me debug why my posts aren't loading from the API
✔️ Awesome, I've got everything!
Great. Double check you've saved src/App.jsx and move on.
ⓧ I'd like to double check the full code
Here is the complete src/App.jsx at this point. Compare it with yours character by character.
import { useState, useEffect } from 'react'
import PostCard from './PostCard'
import './App.css'
function App() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then((response) => response.json())
.then((data) => {
console.log(data)
setPosts(data)
})
}, [])
return (
<div className="app">
<h1>Post Explorer</h1>
<div className="post-grid">
{posts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
</div>
)
}
export default App
Your app now pulls live data from a real API. But that flash of empty content before the posts appear is a problem. Next up, you'll add a loading spinner and error handling so users always know what's happening.
Add Loading and Error States
Your app now pulls 100 real posts from the JSONPlaceholder API and displays them in a card grid. That is a big upgrade from hardcoded data.
There is a problem, though. While the data loads, the page shows a blank screen with no feedback. If the network fails, the app silently breaks with nothing to tell the user what happened. In this step, you will add loading and error states so users always know what is going on.
In this step, get ready to:
- Rewrite the fetch logic with try/catch/finally for proper loading and error handling.
- Conditionally render a loading message, an error message, or the post grid based on the app's state.
- Test the error state by intentionally breaking the API URL.
Rewrite the fetch with loading and error handling
Right now your useEffect uses a .then() chain with no error handling. You will replace it with an async/await pattern wrapped in try/catch/finally, which makes the loading and error flow much clearer to read.
- In src/App.jsx, find the state declaration and the entire useEffect block:
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts')
.then((response) => response.json())
.then((data) => {
console.log(data)
setPosts(data)
})
}, [])
- Replace that entire block (from const [posts through }, [])) with the following code:
const [posts, setPosts] = useState([])
// Track whether data is still loading
const [loading, setLoading] = useState(true)
// Track any error that occurs during fetch
const [error, setError] = useState(null)
useEffect(() => {
async function fetchPosts() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
if (!response.ok) {
throw new Error('Failed to fetch posts')
}
const data = await response.json()
setPosts(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [])
What does this code do?
You added two new pieces of state alongside posts.
- loading starts as true because data is loading the moment the component mounts. It flips to false in the finally block, which runs whether the fetch succeeds or fails.
- error starts as null (no error). If the fetch fails, the catch block stores the error message in state.
- if (!response.ok) catches server errors (like 404 or 500) that fetch does not throw on by default.
- Save src/App.jsx.
- Switch back to your browser at http://localhost:5173. The page should still show all 100 post cards, confirming your rewrite did not break anything.
Seeing a blank page or errors in the console?
- Check that the async function fetchPosts() is defined inside the useEffect callback, not outside it.
- Make sure you have fetchPosts() called after the function definition, still inside the useEffect.
- Confirm the closing }, []) at the end of the useEffect still has the empty dependency array.
Help me debug my useEffect rewrite in App.jsx
Render loading and error messages in the UI
The state variables are tracking loading and errors behind the scenes, but the UI does not react to them yet. You need conditional rendering so the component shows different content depending on the current state.
- In src/App.jsx, find the line return ( (the start of your JSX). Add the following code directly above it:
// Show a loading message while data is being fetched
if (loading) {
return <div className="loading">Loading posts...</div>
}
// Show an error message if the fetch failed
if (error) {
return <div className="error">Something went wrong: {error}</div>
}
What does this code do?
These two if statements are called early returns. They short-circuit the component before it reaches the main return with the post grid.
If loading is true, the user sees "Loading posts..." and nothing else. If error is set, they see the error message. Only when both checks pass does React render the post cards.
- Save src/App.jsx.
- Switch back to your browser and refresh the page. You should see "Loading posts..." flash briefly before the 100 post cards appear.
Not seeing the loading message?
- The loading message may disappear very quickly on a fast connection. That is normal. You will confirm it is working properly when you test the error state shortly.
- Make sure the two if blocks are placed above the main return ( statement, not inside it.
Help me debug my conditional rendering in App.jsx
Now add styles so the loading and error messages look polished instead of left-aligned default text.
- In src/App.css, scroll to the bottom of the file and add the following styles below the existing .post-body rule:
/* Center loading, error, and empty-results messages */
.loading,
.error,
.no-results {
text-align: center;
padding: 3rem;
font-size: 1.25rem;
}
.error {
color: #e74c3c;
}
.no-results {
color: #888;
}
What does this code do?
- The shared rule centers and pads the .loading, .error, and .no-results messages so they sit in the middle of the page.
- The .error class gets a red color so error messages stand out. The .no-results class is for a later feature. You are adding it now so the styles are ready.
- Save src/App.css.
- Refresh your browser. The page loads the post cards as before. The centered styling will be visible when you trigger the loading or error states next.
Test the error state
The best way to confirm your error handling works is to break something on purpose and see how the app responds.
- In src/App.jsx, find this line inside your fetchPosts function:
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
- Change the URL to an invalid address by adding BROKEN to the end, so the line looks like this:
const response = await fetch('https://jsonplaceholder.typicode.com/postsBROKEN')
- Save src/App.jsx.
Before you check your browser, what do you think you will see? Will the posts load, a blank page appear, or something else?
- Switch back to your browser.
You should see a centered red message: "Something went wrong: Failed to fetch posts". That is your error state working exactly as designed.
Why does this prove the error handling works?
The broken URL returns a 404 response. Your if (!response.ok) check catches that and throws an error. The catch block stores the message in state, and the conditional rendering displays it. Without this setup, the app would have shown a blank page with no explanation.
Now fix the URL so your app works again.
- In src/App.jsx, find this line:
const response = await fetch('https://jsonplaceholder.typicode.com/postsBROKEN')
- Remove BROKEN from the end so the URL is correct again:
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
- Save src/App.jsx and switch back to your browser.
You should see "Loading posts..." appear briefly in the center of the page, followed by all 100 post cards loading in. Both states are working.
Still seeing the error message after fixing the URL?
- Double-check that the URL reads exactly https://jsonplaceholder.typicode.com/posts with no typos or extra characters.
- Make sure you saved the file after editing. Unsaved changes will not trigger a hot reload.
Help me fix my API URL in App.jsx
Time to double check your code before moving on.
✔️ Awesome, I've got everything!
Make sure you have saved both src/App.jsx and src/App.css with the corrected API URL.
ⓧ I'd like to double check the full code
Here is the full src/App.jsx at this point. Scroll through and confirm each section matches yours.
- The imports at the top (useState, useEffect, PostCard, App.css) are unchanged.
- Three state variables: posts (empty array), loading (true), error (null).
- The useEffect with the async fetchPosts() function using try/catch/finally.
- Two early returns: one for loading, one for error, placed above the main return.
- The main return renders the heading and post grid, unchanged from the previous step.
Here is the full src/App.css. Confirm the new styles at the bottom match yours.
- All the existing styles (.app, .post-grid, .post-card, .post-title, .post-body) are unchanged.
- At the bottom: the shared .loading, .error, .no-results rule, then separate rules for .error (red) and .no-results (gray).
Your app now handles both happy and unhappy paths gracefully. Users see a loading message while data arrives and a clear error message if something goes wrong. Next up, you will add a search bar so users can find specific posts without scrolling through all 100.
Add Search Filtering
Your app now handles loading spinners and error messages gracefully. But with 100 posts on screen, finding a specific one means scrolling forever.
In this step, you will add a search bar that filters posts in real time as you type. This introduces two core React patterns: controlled inputs and derived state.
In this step, get ready to:
- Add a search input that tracks what the user types.
- Filter the posts array so only matching titles appear.
- Display a friendly message when no posts match the search.
Add a search input with state
A controlled input is an input whose value is driven by React state. Every keystroke updates the state, and the state feeds back into the input's value. This keeps React as the single source of truth for what the user typed.
- In App.jsx, find this line near the top of the App function:
const [error, setError] = useState(null)
- Add this new state variable directly below it:
const [searchTerm, setSearchTerm] = useState('')
- Now scroll down to the return statement and find this line:
<h1>Post Explorer</h1>
- Add this search input element directly below the heading:
{/* Search input: controlled by searchTerm state */}
<input
type="text"
className="search-input"
placeholder="Search posts by title..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
What does this code do?
- searchTerm stores whatever the user has typed so far, starting as an empty string.
- value={searchTerm} ties the input's displayed text to the state. React controls what appears in the box.
- onChange={(e) => setSearchTerm(e.target.value)} fires on every keystroke and updates the state with the new value.
- Save App.jsx.
- Check your browser. You should see an unstyled text input appear below the heading.
The input looks rough because it has no CSS yet. Time to fix that.
- In App.css, find the closing brace of the .app h1 rule. Add these new styles directly below it:
/* Full-width search bar with rounded corners */
.search-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s;
}
/* Highlight border color when the input is focused */
.search-input:focus {
border-color: #646cff;
}
/* Centered post count below the search bar */
.post-count {
text-align: center;
color: #888;
margin: 1rem 0;
}
What do these styles do?
- .search-input makes the search bar span the full width with comfortable padding and rounded corners.
- .search-input:focus changes the border to purple when the learner clicks into the input, giving visual feedback.
- .post-count styles the results counter you will add in the next substep.
- Save App.css and check your browser.
- You should see a clean, full-width search bar with rounded corners below the heading. Click into it and the border turns purple.
Search bar not styled?
- Make sure you saved both App.jsx and App.css. Unsaved changes will not appear in the browser.
- Check that your input element has className="search-input" matching the CSS class name exactly.
- Confirm the new CSS rules go after the .app h1 block and before the .post-grid block.
Why isn't my search input styled in the browser?
Filter the posts by search term
The search bar captures input, but typing in it does nothing to the posts below. You need to create a filtered version of the posts array that recomputes every time searchTerm changes.
- In App.jsx, find the closing of the useEffect block:
}, [])
- Add the filter logic directly below it:
// Filter posts to match the search term
const filteredPosts = posts.filter((post) =>
post.title.toLowerCase().includes(searchTerm.toLowerCase())
)
What is derived state?
filteredPosts is not its own piece of state. It is derived from two existing state values: posts and searchTerm.
Every time either value changes, React re-renders the component and filteredPosts recomputes automatically. No extra useState needed.
Now you need to tell the JSX to use filteredPosts instead of the full posts array.
- Scroll down to the return statement and find this line inside the post-grid div:
{posts.map((post) => (
- Replace posts.map with filteredPosts.map so it looks like this:
{filteredPosts.map((post) => (
- Save App.jsx and switch to your browser.
- Type qui into the search bar. You should see the post list shrink down to only posts with "qui" in their title.
- Clear the search bar and all 100 posts return.
The filter works, but the user has no count of how many posts match. A quick addition fixes that.
- In App.jsx, find the closing /> of the input element you just added. Add this line directly below it:
<p className="post-count">{filteredPosts.length} posts found</p>
- Save App.jsx and check the browser.
- You should see "100 posts found" centered below the search bar. Type qui again and the count updates in real time.
Posts not filtering when you type?
- Make sure you changed posts.map to filteredPosts.map in the JSX. If you still map over posts, the filter has no effect on what renders.
- Check that the filteredPosts variable is declared outside the return statement but inside the App function, after the useEffect block.
Why aren't my posts filtering when I type in the search bar?
Handle empty search results
Try typing a random string like xyzabc into the search bar. The post grid disappears and the count says "0 posts found", but the page looks empty and confusing. A clear message tells the user nothing matched.
- In App.jsx, find the post-grid div in the return statement:
<div className="post-grid">
{filteredPosts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
- Replace that entire block with this conditional rendering:
{/* Show no-results message or the post grid */}
{filteredPosts.length === 0 ? (
<p className="no-results">No posts found matching “{searchTerm}”</p>
) : (
<div className="post-grid">
{filteredPosts.map((post) => (
<PostCard key={post.id} title={post.title} body={post.body} />
))}
</div>
)}
What does this code do?
- The ternary operator (? :) checks if filteredPosts.length === 0.
- If true, it renders a paragraph with the no-results class showing exactly what the user searched for.
- If false, it renders the normal post grid. This is conditional rendering, one of React's most common patterns.
Before you test this, what do you think will happen if you type xyzabc into the search bar now?
- Save App.jsx and type xyzabc into the search bar.
You should see the message "No posts found matching “xyzabc”" and "0 posts found" in the count. Clear the search bar and all 100 posts return.
- Now type qui into the search bar. Only posts with "qui" in their title appear, and the count updates to match.
- Clear the search bar one more time and confirm all 100 posts return.
Seeing an error instead of the no-results message?
- Check that the ternary wraps the entire block. The opening {filteredPosts.length === 0 ? ( must come before the no-results paragraph, and the closing )} must come after the post-grid div.
- Make sure you removed the old <div className="post-grid"> block entirely before pasting the replacement. Duplicate blocks cause syntax errors.
Help me fix my conditional rendering in the Post Explorer app.
✔️ Awesome, I've got everything!
Great. Double check you have saved both App.jsx and App.css.
ⓧ I'd like to double check the full code
Scroll up through this step and confirm each piece is in place, in order.
In App.jsx:
- The searchTerm state variable sits after the error state line.
- The filteredPosts filter logic sits after the useEffect block.
- The input element sits below the h1 heading in the return statement.
- The post-count paragraph sits below the input.
- The ternary conditional wraps the no-results paragraph and the post-grid div.
In App.css:
- .search-input, .search-input:focus, and .post-count rules sit between .app h1 and .post-grid.
Your Post Explorer now fetches, displays, and filters 100 live posts in real time. Next up, take on the Secret Mission to merge author data from a second API endpoint.
Secret mission
Merge User Data into Posts
Right now, every post is anonymous. Real apps combine data from multiple sources to give users the full picture. In this mission, you will fetch user data from a second API endpoint and display each post's author name on every card.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files, pause your dev server to free up your terminal, or delete everything entirely.
Resources you used:
- The post-explorer project folder on your Desktop.
- The Vite dev server running in your terminal.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your Post Explorer app.
- Your Vite dev server stays running in your terminal. You can keep editing files and see changes live at http://localhost:5173.
- Your post-explorer folder stays on your Desktop with all your project files.
- Node.js remains installed on your machine for future projects.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your project files stay on your Desktop so you can pick up where you left off.
- Switch to the terminal where your Vite dev server is running.
- Press Ctrl+C to stop the dev server.
- When you are ready to come back, open your terminal, navigate to your post-explorer folder, and run npm run dev to start the dev server again.
Delete - I don't want to use this again
Remove all project files and stop the dev server. This frees up disk space and cleans your Desktop.
- Switch to the terminal where your Vite dev server is running.
- Press Ctrl+C to stop the dev server.
- Delete the project folder by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf post-explorer
Windows
cd ~\Desktop
rmdir /s /q post-explorer
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see post-explorer in the output.
Nice Work!
Nice Work!
Nice work! You just built a fully functional Post Explorer app with React that fetches live data from an API and lets you search through 100 posts in real time.
You've learned how to:
- Scaffolded a React project with Vite and built reusable components (App and PostCard) that pass data through props.
- Fetched live data from the JSONPlaceholder API using useEffect and fetch, with polished loading states and error handling.
- Built a real-time search filter using controlled inputs and derived state to narrow down posts as you type.
- Secret Mission: Fetched from two API endpoints in parallel with Promise.all and merged user data into posts so every card displays its author.
Ready to quiz yourself?