React Movie Search Application

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.

  1. 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 react
    cd movie-search
    npm install
  2. 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.

  3. Store the key in an environment file. In the project root, create a file named .env and put your key in it. With Vite, the variable name must start with VITE_.

    Terminal window
    VITE_OMDB_API_KEY=your_key_here
  4. Keep the key out of Git. Open .gitignore and make sure .env is listed there. It usually is already, but check.

  5. Restart the dev server after editing .env. Vite only reads env files when it starts. So stop the server and run npm run dev again 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.json

We 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 s parameter means “search”, and type=movie keeps 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 Response field, 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"
}

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: value is the current text, and onChange is 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.
  • autoFocus puts 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_POSTER is 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 alt text 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.
  • onError is 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 movies array and maps each one to a MovieCard.
  • Each card gets key={movie.imdbID}. The imdbID is 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 debouncedValue in state, which is the value “after the pause”.
  • Every time value changes, the useEffect runs and starts a timer for delay milliseconds.
  • 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, and error.
  • debouncedQuery is the calmed-down version of query from 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 a useEffect callback itself cannot be async.
  • We set loading true, clear old errors, fetch, then store the results. The finally block 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 / 3 keeps every poster the same shape, so even the fallback placeholder lines up neatly with real posters.
  • object-fit: cover makes posters fill their space without stretching, so nothing looks squashed.
  • The hover lift and shadow on .movie-card give it that polished, clickable feel.

🧩 The Complete Code

Here is every file in one place, so you have the whole working app together.

.env

Terminal window
VITE_OMDB_API_KEY=your_key_here

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;
}

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.

Terminal window
npm run dev

Open 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 matching
movie 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 with VITE_.

    Terminal window
    # ❌ undefined in the app
    OMDB_API_KEY=abc123
    # ✅ readable via import.meta.env.VITE_OMDB_API_KEY
    VITE_OMDB_API_KEY=abc123
  • Not restarting after editing .env. Vite reads env files only at startup, so a change won’t show until you restart npm run dev.

  • Making the effect callback async. A useEffect callback must not return a promise.

    // ❌ this returns a promise, not a cleanup function
    useEffect(async () => { await searchMovies(q); }, [q]);
    // ✅ define an async function inside and call it
    useEffect(() => { 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 key
    movies.map((m, i) => <MovieCard key={i} movie={m} />)
    // ✅ stable unique id
    movies.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 ignore flag, 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 totalResults count. Add a “Load more” button that requests the next page.
  • A movie detail view. Clicking a card could fetch the full details by imdbID and show plot, rating, and cast. Pair it with React Router for a /movie/:id page.
  • A favorites list. Let users save movies and keep them in localStorage so 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 fetch and async/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. 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. 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. 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. 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.

React Expense Tracker

Share & Connect