React Movie Search Application
Table of Contents + −
In the React Weather Application you fetched live data for one city. Now we’ll build something with a list and a grid: a movie search app that talks to a real public API and shows posters as you type.
🎯 What We’re Building
We’re building a movie search app. You type a movie name, and a grid of movie cards appears. Each card shows the poster, the title, and the year.
It feels small, but it touches almost everything you use in a real React app. Here is what it does:
- A search box that fetches matching movies from a real movie API.
- Debounced typing, so it waits a moment after you stop typing before it sends a request.
- A responsive grid of movie cards with poster, title, and year.
- Clear loading, error, and “no results” states.
- A fallback image when a movie has no poster.
Here is what the finished screen looks like when you search for “batman”.
Output
┌─────────────────────────────────────────────┐│ 🎬 Movie Finder ││ ┌───────────────────────────────────────┐ ││ │ batman 🔍 │ ││ └───────────────────────────────────────┘ ││ ││ ┌────────┐ ┌────────┐ ┌────────┐ ││ │ poster │ │ poster │ │ poster │ ││ │ │ │ │ │ │ ││ ├────────┤ ├────────┤ ├────────┤ ││ │ Batman │ │ Batman │ │ Batman │ ││ │ Begins │ │ Returns│ │ Forever│ ││ │ 2005 │ │ 1992 │ │ 1995 │ ││ └────────┘ └────────┘ └────────┘ │└─────────────────────────────────────────────┘We’ll use the OMDb API. It is free, it is simple, and a search returns exactly the fields we need: a title, a year, and a poster URL.
🗂️ Project Setup
First we make the project, get an API key, and lay out the files. We’ll use Vite because it starts fast.
-
Create the project with Vite. Run this in your terminal. It scaffolds a fresh React app.
Terminal window npm create vite@latest movie-search -- --template reactcd movie-searchnpm install -
Get a free OMDb API key. Go to omdbapi.com, open the “API Key” tab, pick the free tier, and enter your email. They send you a key. You click the link in the email to activate it.
-
Store the key in an environment file. In the project root, create a file named
.envand put your key in it. With Vite, the variable name must start withVITE_.Terminal window VITE_OMDB_API_KEY=your_key_here -
Keep the key out of Git. Open
.gitignoreand make sure.envis listed there. It usually is already, but check. -
Restart the dev server after editing
.env. Vite only reads env files when it starts. So stop the server and runnpm run devagain whenever you change.env.
Caution
An API key in a frontend app is never truly secret. Anyone can open the browser network tab and see it. Using import.meta.env keeps the key out of your source code and out of Git, which is good practice. But for a key that must stay private, you put it on a backend server and call the API from there. The OMDb free key is fine to use this way for learning.
Tip
The OMDb free tier allows about 1,000 requests per day. Debouncing the search box does more than save bandwidth. It also protects your daily quota while you test, so you don’t burn through the limit by firing a request on every keystroke.
Here is the file structure we’re aiming for. The new files are the ones we’ll write.
movie-search/├── .env ← your API key (not in Git)├── index.html├── src/│ ├── api/│ │ └── omdb.js ← the fetch logic│ ├── components/│ │ ├── SearchBar.jsx ← the input│ │ ├── MovieCard.jsx ← one card│ │ └── MovieGrid.jsx ← the grid of cards│ ├── App.jsx ← ties it all together│ ├── App.css ← the styles│ └── main.jsx└── package.jsonWe split the work into small pieces. One file fetches data. One file is the search box. One file is a single card. One file is the grid. And App.jsx connects them. Keeping each job in its own file makes the app easier to read and to change later.
🌐 Step 1: The API Call
Before any UI, let’s write the function that actually talks to OMDb. Putting fetch logic in its own file keeps our components clean.
Create src/api/omdb.js.
const API_KEY = import.meta.env.VITE_OMDB_API_KEY;const BASE_URL = "https://www.omdbapi.com/";
export async function searchMovies(query) { const url = `${BASE_URL}?apikey=${API_KEY}&type=movie&s=${encodeURIComponent(query)}`;
const response = await fetch(url);
if (!response.ok) { throw new Error("Network error. Please try again."); }
const data = await response.json();
if (data.Response === "False") { if (data.Error === "Movie not found!") { return []; } throw new Error(data.Error || "Something went wrong."); }
return data.Search;}The load-bearing parts are these:
- It reads the key from
import.meta.env.VITE_OMDB_API_KEY, so the key lives in.env, not in this file. - It builds the URL. The
sparameter means “search”, andtype=moviekeeps results to movies only. - OMDb is unusual. Even on a “no results” reply, the HTTP status is still 200. It tells you the real result in a
Responsefield, so we read that instead of trusting the status code alone.
A few smaller details round it out. encodeURIComponent(query) makes the text safe for a URL, so a search like “spider man” doesn’t break the address. If the network itself fails, response.ok is false, so we throw. When OMDb’s error is literally “Movie not found!”, that is not a crash, so we return an empty array. For any other OMDb error we throw, so the UI can show a message. On success, OMDb puts the list of movies in data.Search, so we return that.
The why: by handling the network error, the “no results” case, and other API errors right here, the rest of the app only ever sees a clean list or an error. That keeps our components simple.
Here is roughly what one movie in data.Search looks like. We’ll use Poster, Title, Year, and imdbID.
Output
Representative OMDb result (one item from data.Search):
{ "Title": "Batman Begins", "Year": "2005", "imdbID": "tt0372784", "Type": "movie", "Poster": "https://m.media-amazon.com/images/.../poster.jpg"}⌨️ Step 2: The Search Bar
Now the input. We want a controlled input, which means React state holds the current text and the input shows that state.
Create src/components/SearchBar.jsx.
function SearchBar({ value, onChange }) { return ( <div className="search-bar"> <input type="text" className="search-input" placeholder="Search for a movie..." value={value} onChange={(e) => onChange(e.target.value)} autoFocus /> <span className="search-icon">🔍</span> </div> );}
export default SearchBar;Here is what is happening:
- The component takes two props:
valueis the current text, andonChangeis a function to call when the text changes. value={value}makes it a controlled input, so React is the single source of truth for what’s in the box.onChange={(e) => onChange(e.target.value)}reads the typed text and passes just the string up to the parent.autoFocusputs the cursor in the box when the page loads, so the user can type right away.
The why: this component is “dumb” on purpose. It does not fetch anything and it does not store the text itself. It only shows the value it is given and reports changes. The parent owns the state. That makes the search bar easy to reuse and easy to test.
🃏 Step 3: One Movie Card
Each result is a card with a poster, a title, and a year. Let’s build a single card first, then we’ll render many of them.
Create src/components/MovieCard.jsx.
const FALLBACK_POSTER = "https://placehold.co/300x445?text=No+Poster";
function MovieCard({ movie }) { const poster = movie.Poster && movie.Poster !== "N/A" ? movie.Poster : FALLBACK_POSTER;
return ( <article className="movie-card"> <img className="movie-poster" src={poster} alt={`${movie.Title} poster`} loading="lazy" onError={(e) => { e.currentTarget.src = FALLBACK_POSTER; }} /> <div className="movie-info"> <h3 className="movie-title">{movie.Title}</h3> <span className="movie-year">{movie.Year}</span> </div> </article> );}
export default MovieCard;Let’s read through it:
FALLBACK_POSTERis a plain placeholder image URL we use when a real poster is missing.- OMDb sends the text
"N/A"when there is no poster, so we check for that and for an empty value, then pick the fallback if needed. - The
alttext describes the image for screen readers and shows if the image can’t load. loading="lazy"tells the browser to only load posters as they scroll into view, which keeps a big grid fast.onErroris the safety net. If a poster URL is broken even though it wasn’t"N/A", the image swap happens right there so the user never sees a broken-image icon.
The why: posters are the part most likely to fail. A missing poster, a dead link, a slow image. We handle all three: the "N/A" check, the fallback URL, and the onError swap. The card never looks broken.
🔲 Step 4: The Grid Of Cards
Now we render a list of cards. This is where keys matter.
Create src/components/MovieGrid.jsx.
import MovieCard from "./MovieCard";
function MovieGrid({ movies }) { return ( <div className="movie-grid"> {movies.map((movie) => ( <MovieCard key={movie.imdbID} movie={movie} /> ))} </div> );}
export default MovieGrid;What this does:
- It takes a
moviesarray and maps each one to aMovieCard. - Each card gets
key={movie.imdbID}. TheimdbIDis unique per movie, so it’s a perfect key.
The why on keys: React uses the key to tell items apart between renders. With a stable, unique key like imdbID, React updates the grid efficiently and keeps the right card in the right place. Never use the array index as the key here, because results reorder when the search changes, and an index key would make React confuse one card for another.
⏳ Step 5: Debouncing The Input
Here is the heart of a good search box. If we fetched on every keystroke, typing “batman” would fire six requests. That is wasteful and it can hit the API rate limit. So we debounce: we wait a short pause after the user stops typing, then fetch once.
We’ll make a small reusable hook. Create src/useDebounce.js.
import { useState, useEffect } from "react";
export function useDebounce(value, delay = 500) { const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay);
return () => clearTimeout(timer); }, [value, delay]);
return debouncedValue;}How it works:
- It holds a
debouncedValuein state, which is the value “after the pause”. - Every time
valuechanges, theuseEffectruns and starts a timer fordelaymilliseconds. - If the user types again before the timer finishes, the cleanup function
clearTimeout(timer)cancels the old timer, and a new one starts. - Only when the user pauses for the full delay does the timer fire and update
debouncedValue.
The why: the cleanup function is the trick. React runs it before the effect runs again, so each keystroke wipes the previous pending timer. The fetch will watch debouncedValue, not the raw text, so it only runs once the typing settles. If you want a refresher on cleanup, see the useEffect hook.
🧠 Step 6: Tying It Together In App
Now App.jsx holds the state and wires every piece together: the search text, the debounced text, the results, the loading flag, and the error.
Replace everything in src/App.jsx with this.
import { useState, useEffect } from "react";import SearchBar from "./components/SearchBar";import MovieGrid from "./components/MovieGrid";import { useDebounce } from "./useDebounce";import { searchMovies } from "./api/omdb";import "./App.css";
function App() { const [query, setQuery] = useState(""); const [movies, setMovies] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState("");
const debouncedQuery = useDebounce(query, 500);
useEffect(() => { const trimmed = debouncedQuery.trim();
if (trimmed === "") { setMovies([]); setError(""); setLoading(false); return; }
let ignore = false;
async function run() { setLoading(true); setError(""); try { const results = await searchMovies(trimmed); if (!ignore) { setMovies(results); } } catch (err) { if (!ignore) { setError(err.message); setMovies([]); } } finally { if (!ignore) { setLoading(false); } } }
run();
return () => { ignore = true; }; }, [debouncedQuery]);
return ( <div className="app"> <header className="app-header"> <h1>🎬 Movie Finder</h1> <p className="tagline">Search thousands of movies instantly</p> </header>
<SearchBar value={query} onChange={setQuery} />
<main className="results"> {loading && <p className="status">Loading movies...</p>}
{!loading && error && <p className="status error">{error}</p>}
{!loading && !error && movies.length > 0 && ( <MovieGrid movies={movies} /> )}
{!loading && !error && debouncedQuery.trim() !== "" && movies.length === 0 && ( <p className="status">No movies found for "{debouncedQuery}".</p> )}
{!loading && !error && debouncedQuery.trim() === "" && ( <p className="status hint">Start typing to find a movie 🎥</p> )} </main> </div> );}
export default App;Let’s walk through the important parts:
- We keep four pieces of state:
query(raw text),movies(results),loading, anderror. debouncedQueryis the calmed-down version ofqueryfrom our hook. The effect depends on it, so the fetch only runs after the user pauses.- If the debounced text is empty, we clear everything and stop early. No empty searches.
- Inside the effect we define an
async function run()because auseEffectcallback itself cannot beasync. - We set
loadingtrue, clear old errors, fetch, then store the results. Thefinallyblock turns loading off no matter what. - The render shows exactly one state at a time: loading, or error, or the grid, or “no results”, or the starting hint.
The why on ignore: imagine a user types “bat” then quickly types “batman”. Two requests are now in flight. If the slower “bat” reply arrives last, it would overwrite the “batman” results. That is a race condition. The cleanup sets ignore = true for the old effect, so a stale reply is thrown away. Only the latest search updates the screen.
🎨 Styling
Now let’s make it look like a real product. The two important parts are the search bar and the responsive grid. The grid uses CSS Grid with auto-fill and minmax, so the number of columns changes with the screen width all on its own. No media queries needed.
Replace src/App.css with this.
:root { --bg: #0f1117; --card: #1b1f2a; --accent: #ffb703; --text: #f5f5f5; --muted: #9aa0ac;}
* { box-sizing: border-box; margin: 0; padding: 0;}
body { background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, "Segoe UI", sans-serif;}
.app { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 4rem;}
.app-header { text-align: center; margin-bottom: 2rem;}
.app-header h1 { font-size: 2.5rem; letter-spacing: -0.5px;}
.tagline { color: var(--muted); margin-top: 0.4rem;}
/* Search bar */.search-bar { position: relative; max-width: 560px; margin: 0 auto 2.5rem;}
.search-input { width: 100%; padding: 0.9rem 3rem 0.9rem 1.2rem; font-size: 1.05rem; color: var(--text); background: var(--card); border: 2px solid transparent; border-radius: 14px; outline: none; transition: border-color 0.2s ease;}
.search-input:focus { border-color: var(--accent);}
.search-input::placeholder { color: var(--muted);}
.search-icon { position: absolute; right: 1.1rem; top: 50%; transform: translateY(-50%); font-size: 1.1rem; pointer-events: none;}
/* Status messages */.status { text-align: center; color: var(--muted); font-size: 1.1rem; margin-top: 2rem;}
.status.error { color: #ff6b6b;}
.status.hint { opacity: 0.8;}
/* Responsive grid */.movie-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1.4rem;}
/* Card */.movie-card { background: var(--card); border-radius: 14px; overflow: hidden; transition: transform 0.18s ease, box-shadow 0.18s ease;}
.movie-card:hover { transform: translateY(-6px); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);}
.movie-poster { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; display: block; background: #11141c;}
.movie-info { padding: 0.85rem 0.9rem 1.1rem;}
.movie-title { font-size: 1rem; line-height: 1.3; margin-bottom: 0.35rem;}
.movie-year { font-size: 0.85rem; color: var(--accent); font-weight: 600;}The key styling choices, in plain terms:
- The CSS variables at the top hold the colors in one place, so changing the theme is a one-line edit.
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr))is the magic line. It tells the browser to fit as many columns as it can, where each column is at least 180px wide and stretches to fill the row. On a phone you get one or two columns; on a wide screen you get five or six.aspect-ratio: 2 / 3keeps every poster the same shape, so even the fallback placeholder lines up neatly with real posters.object-fit: covermakes posters fill their space without stretching, so nothing looks squashed.- The hover lift and shadow on
.movie-cardgive it that polished, clickable feel.
🧩 The Complete Code
Here is every file in one place, so you have the whole working app together.
.env
VITE_OMDB_API_KEY=your_key_heresrc/api/omdb.js
const API_KEY = import.meta.env.VITE_OMDB_API_KEY;const BASE_URL = "https://www.omdbapi.com/";
export async function searchMovies(query) { const url = `${BASE_URL}?apikey=${API_KEY}&type=movie&s=${encodeURIComponent(query)}`;
const response = await fetch(url);
if (!response.ok) { throw new Error("Network error. Please try again."); }
const data = await response.json();
if (data.Response === "False") { if (data.Error === "Movie not found!") { return []; } throw new Error(data.Error || "Something went wrong."); }
return data.Search;}src/useDebounce.js
import { useState, useEffect } from "react";
export function useDebounce(value, delay = 500) { const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay);
return () => clearTimeout(timer); }, [value, delay]);
return debouncedValue;}src/components/SearchBar.jsx
function SearchBar({ value, onChange }) { return ( <div className="search-bar"> <input type="text" className="search-input" placeholder="Search for a movie..." value={value} onChange={(e) => onChange(e.target.value)} autoFocus /> <span className="search-icon">🔍</span> </div> );}
export default SearchBar;src/components/MovieCard.jsx
const FALLBACK_POSTER = "https://placehold.co/300x445?text=No+Poster";
function MovieCard({ movie }) { const poster = movie.Poster && movie.Poster !== "N/A" ? movie.Poster : FALLBACK_POSTER;
return ( <article className="movie-card"> <img className="movie-poster" src={poster} alt={`${movie.Title} poster`} loading="lazy" onError={(e) => { e.currentTarget.src = FALLBACK_POSTER; }} /> <div className="movie-info"> <h3 className="movie-title">{movie.Title}</h3> <span className="movie-year">{movie.Year}</span> </div> </article> );}
export default MovieCard;src/components/MovieGrid.jsx
import MovieCard from "./MovieCard";
function MovieGrid({ movies }) { return ( <div className="movie-grid"> {movies.map((movie) => ( <MovieCard key={movie.imdbID} movie={movie} /> ))} </div> );}
export default MovieGrid;src/App.jsx
import { useState, useEffect } from "react";import SearchBar from "./components/SearchBar";import MovieGrid from "./components/MovieGrid";import { useDebounce } from "./useDebounce";import { searchMovies } from "./api/omdb";import "./App.css";
function App() { const [query, setQuery] = useState(""); const [movies, setMovies] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState("");
const debouncedQuery = useDebounce(query, 500);
useEffect(() => { const trimmed = debouncedQuery.trim();
if (trimmed === "") { setMovies([]); setError(""); setLoading(false); return; }
let ignore = false;
async function run() { setLoading(true); setError(""); try { const results = await searchMovies(trimmed); if (!ignore) { setMovies(results); } } catch (err) { if (!ignore) { setError(err.message); setMovies([]); } } finally { if (!ignore) { setLoading(false); } } }
run();
return () => { ignore = true; }; }, [debouncedQuery]);
return ( <div className="app"> <header className="app-header"> <h1>🎬 Movie Finder</h1> <p className="tagline">Search thousands of movies instantly</p> </header>
<SearchBar value={query} onChange={setQuery} />
<main className="results"> {loading && <p className="status">Loading movies...</p>}
{!loading && error && <p className="status error">{error}</p>}
{!loading && !error && movies.length > 0 && ( <MovieGrid movies={movies} /> )}
{!loading && !error && debouncedQuery.trim() !== "" && movies.length === 0 && ( <p className="status">No movies found for "{debouncedQuery}".</p> )}
{!loading && !error && debouncedQuery.trim() === "" && ( <p className="status hint">Start typing to find a movie 🎥</p> )} </main> </div> );}
export default App;The full App.css is in the Styling section above.
▶️ Run It
Make sure your .env has a real key, then start the dev server.
npm run devOpen the address it prints (usually http://localhost:5173). Type a movie name and watch the grid fill in after a short pause.
Output
VITE v5.x ready in 412 ms
➜ Local: http://localhost:5173/➜ press h + enter to show help
In the browser, searching "inception" shows a grid of matchingmovie posters with titles and years. A search with no matches shows"No movies found." Network results are representative.⚠️ Common Mistakes
A few traps catch almost everyone the first time:
-
Forgetting the
VITE_prefix. Vite only exposes env variables that start withVITE_.Terminal window # ❌ undefined in the appOMDB_API_KEY=abc123# ✅ readable via import.meta.env.VITE_OMDB_API_KEYVITE_OMDB_API_KEY=abc123 -
Not restarting after editing
.env. Vite reads env files only at startup, so a change won’t show until you restartnpm run dev. -
Making the effect callback
async. AuseEffectcallback must not return a promise.// ❌ this returns a promise, not a cleanup functionuseEffect(async () => { await searchMovies(q); }, [q]);// ✅ define an async function inside and call ituseEffect(() => { async function run() { await searchMovies(q); } run(); }, [q]); -
Using the array index as a key. Results reorder on each search, so an index key confuses React.
// ❌ index keymovies.map((m, i) => <MovieCard key={i} movie={m} />)// ✅ stable unique idmovies.map((m) => <MovieCard key={m.imdbID} movie={m} />) -
Treating OMDb’s “no results” as an error. OMDb returns HTTP 200 even when nothing matches, so check
data.Response, not just the status code.
✅ Best Practices
These are the habits this project quietly follows:
- Keep fetch logic in its own file, so components stay focused on the UI.
- Read secrets from
import.meta.env, never hard-code them in source. - Debounce user-driven requests, so you don’t flood the API or burn through your daily quota.
- Cancel stale responses with an
ignoreflag, so the latest search always wins. - Always handle loading, error, and empty states, not just the happy path.
- Give list items a stable, unique key.
- Plan for missing data, like a poster that is
"N/A"or a broken image URL.
🚀 Extend It / Make It Your Own
Once it works, here are good upgrades to try:
- Pagination. OMDb returns ten results per page and a
totalResultscount. Add a “Load more” button that requests the next page. - A movie detail view. Clicking a card could fetch the full details by
imdbIDand show plot, rating, and cast. Pair it with React Router for a/movie/:idpage. - A favorites list. Let users save movies and keep them in
localStorageso they stay around after a refresh. - A year or type filter. Add a dropdown to filter by year, or switch between movies, series, and episodes.
- A nicer loading state. Replace the “Loading…” text with skeleton cards that match the grid shape.
🧩 What You’ve Learned
You built a complete, polished app. Along the way you practiced:
- ✅ Fetching data from a real API with
fetchandasync/await. - ✅ Reading an API key safely from
import.meta.env. - ✅ Debouncing input so search fires once, not on every keystroke.
- ✅ Handling loading, error, and empty states cleanly.
- ✅ Rendering a list with stable, unique keys.
- ✅ Building a responsive card grid with CSS Grid.
- ✅ Guarding against missing data with a poster fallback.
- ✅ Avoiding race conditions with an effect cleanup flag.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why do we debounce the search input?
Why: Debouncing waits a short delay after typing stops, so a word like 'batman' triggers one fetch instead of six.
- 2
Why must the value passed to key be stable and unique, like imdbID?
Why: React tracks list items by key. The array index changes when results reorder, so a stable id like imdbID is correct.
- 3
Why can't the useEffect callback itself be async?
Why: useEffect expects its return value to be a cleanup function. An async callback returns a promise, so we define an async function inside and call it.
- 4
OMDb returns HTTP 200 even when no movies match. How do we detect 'no results'?
Why: OMDb signals failures in the body, not the status code. We read data.Response and treat 'Movie not found!' as an empty list.
🚀 What’s Next?
Next we’ll build an app that tracks money in and money out, with totals that update live as you add entries.