Overview
A movie search app is the project where `useEffect` really earns its place, because it has to synchronize a component with something outside React's control — a network request — every time the user's search term changes. That brings in the exact real-world problems every data-fetching component eventually hits: showing a loading state while a request is pending, showing an error state if it fails, and — trickier — handling the case where the user types fast enough that an *older* request resolves after a *newer* one.
This tutorial searches the iTunes Search API, a free, no-key, CORS-enabled endpoint that returns real movie data — no account or API key needed, which keeps the focus on React instead of authentication setup.
- A controlled search input backed by `useState`.
- A `useEffect` hook that fetches movies whenever the search term changes.
- A debounce (via `setTimeout` and a cleanup function) so a request only fires after the user pauses typing.
- Request cancellation with `AbortController` so a slow, stale request can never overwrite fresher results.
- `loading`, `error`, and `results` state, each driving its own conditionally-rendered piece of UI.
- A responsive grid of movie cards built from the live API response.
Prerequisites
- JSX, function components, and `useState` — covered in the Todo App project above.
- Promises and `async`/`await`, and `try`/`catch` error handling.
- The Fetch API — calling `fetch(url)` and reading a `Response` with `.json()`.
- Conditional rendering with `&&` and ternaries inside JSX.
- `useEffect`, cleanup functions, and `AbortController` are new here — every one is introduced from scratch in this tutorial.
Project Structure
This project is small enough to stay in a single `App` component — the interesting logic is entirely about *when* the fetch happens and *what* the four possible outcomes (idle, loading, error, results) look like, not about splitting up markup. `query`, `movies`, `loading`, and `error` are four independent pieces of state that all live in `App`, each updated by the same `useEffect`.
src/ App.jsx // Search input, useEffect data-fetching logic, and all conditional rendering index.css // Shared dark-theme styling* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.movie-app { width: 100%; max-width: 640px; }.movie-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.movie-app input { width: 100%; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #23262e; color: #e8e8e8; font-size: 1rem; margin-bottom: 16px;}
.status { color: #9aa4b2; padding: 12px 0; }.status.error { color: #ff6b6b; }
.movie-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px;}
.movie-card { background: #23262e; border-radius: 8px; overflow: hidden; }.movie-card img { width: 100%; display: block; }.movie-card h3 { font-size: 0.85rem; padding: 8px 8px 2px; }.movie-card p { font-size: 0.75rem; color: #9aa4b2; padding: 0 8px 8px; }Step 1: Set Up State and a Controlled Search Input
Four separate state variables track four independent facts: what is typed, what came back, whether a request is in flight, and whether the last request failed. Keeping them separate (instead of one combined object) means each can be updated without having to spread and merge the others every time.
import { useState, useEffect } from 'react';
function App() { const [query, setQuery] = useState(''); // exactly what's currently typed in the search box const [movies, setMovies] = useState([]); // the most recent successful search results const [loading, setLoading] = useState(false); // true only while a request is actually in flight const [error, setError] = useState(null); // an error message string, or null when there isn't one
return ( <div className="movie-app"> <h1>Movie Search</h1> <input type="text" value={query} // controlled: state is the only source of truth for the box's contents onChange={(event) => setQuery(event.target.value)} placeholder="Search for a movie..." /> {/* Loading / error / results rendering is added in Steps 5 and 6 */} </div> );}
export default App;Step 2: Fetch Movies Inside useEffect
A `useEffect` callback runs after every render where its dependencies changed — with `[query]` as the dependency array, that means it re-runs each time the search term changes. The effect function itself can't be declared `async` (React expects it to return either nothing or a cleanup function, not a `Promise`), so an inner `async` function is declared and called immediately instead.
useEffect(() => { // Skip firing a request for an empty box — otherwise the very first // render would search for "" and show an arbitrary wall of results if (query.trim() === '') { setMovies([]); setError(null); return; }
async function fetchMovies() { setLoading(true); setError(null);
try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(query); // encodeURIComponent safely escapes spaces/punctuation in the search term const response = await fetch(url);
if (!response.ok) { throw new Error('Search request failed.'); }
const data = await response.json(); setMovies(data.results); } catch (err) { setError(err.message); setMovies([]); } finally { setLoading(false); // runs whether the request succeeded or failed } }
fetchMovies();}, [query]); // re-runs this whole effect every time the search box's value changesThis version works, but it fires a network request on every single keystroke — typing "batman" triggers six separate searches for "b", "ba", "bat", and so on. The next two steps fix that.
Step 3: Debounce the Search
A debounce waits for a pause in typing before actually doing the work. The function returned from a `useEffect` callback is a cleanup function — React calls it right before running the effect again (i.e. on the very next keystroke) and once more when the component unmounts. Clearing the previous `setTimeout` in that cleanup is exactly what turns a delay into a debounce: only the LAST scheduled timeout in a burst of keystrokes ever gets to run.
useEffect(() => { if (query.trim() === '') { setMovies([]); setError(null); return; }
// Wait 400ms after the last keystroke before actually searching const timeoutId = setTimeout(() => { async function fetchMovies() { setLoading(true); setError(null); try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(query); const response = await fetch(url); if (!response.ok) throw new Error('Search request failed.'); const data = await response.json(); setMovies(data.results); } catch (err) { setError(err.message); setMovies([]); } finally { setLoading(false); } } fetchMovies(); }, 400);
// React runs this BEFORE the next effect (i.e. on the very next keystroke). // Clearing the timeout here is what makes this a debounce instead of a // plain delay — every keystroke cancels whatever the previous one scheduled return () => clearTimeout(timeoutId);}, [query]);Step 4: Cancel Stale Requests with AbortController
Debouncing controls when a request *starts*, but a slow network can still let an older request *finish* after a newer one — searching "cat" then quickly "car" could, without this step, show "cat" results last if that request happens to resolve second. `AbortController` lets the cleanup function cancel the in-flight request itself, not just the timer that was going to start it.
useEffect(() => { if (query.trim() === '') { setMovies([]); setError(null); return; }
const controller = new AbortController(); // lets this specific request be cancelled if a newer search starts first
const timeoutId = setTimeout(async () => { setLoading(true); setError(null);
try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(query); const response = await fetch(url, { signal: controller.signal }); // ties this fetch to the controller so it can be aborted
if (!response.ok) throw new Error('Search request failed.');
const data = await response.json(); setMovies(data.results); } catch (err) { // A cancelled fetch rejects with an AbortError — that's expected // behavior from cleanup, not a real failure, so it's deliberately // NOT shown to the user as an error if (err.name !== 'AbortError') { setError(err.message); setMovies([]); } } finally { setLoading(false); } }, 400);
// Runs before the next effect (or on unmount): clears the pending timer // AND aborts any request already in flight from a now-stale search term return () => { clearTimeout(timeoutId); controller.abort(); };}, [query]);Step 5: Render Loading, Error, and Empty States
Each of the four states — loading, error, no-results, and idle — is checked independently with `&&`. Because they are computed from mutually exclusive combinations of `loading`/`error`/`movies`, at most one of these lines ever actually renders anything.
{loading && <p className="status">Loading...</p>}
{error && <p className="status error">{error}</p>}
{!loading && !error && query.trim() !== '' && movies.length === 0 && ( <p className="status">No movies found for "{query}".</p>)}Step 6: Render the Movie Grid
Once `movies` has results, `.map()` turns each one into a card. The iTunes API returns `trackId` (a stable unique id), `trackName`, `artworkUrl100` (a small poster image), and `releaseDate` as an ISO string — `.slice(0, 4)` pulls just the year out of it for display.
<div className="movie-grid"> {movies.map((movie) => ( <div className="movie-card" key={movie.trackId}> <img src={movie.artworkUrl100} alt={movie.trackName} /> <h3>{movie.trackName}</h3> <p>{movie.releaseDate ? movie.releaseDate.slice(0, 4) : 'Unknown year'}</p> </div> ))}</div>Complete Code
Here is the complete project, using the real iTunes Search API exactly as you would call it from a live page.
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px; }.movie-app { width: 100%; max-width: 640px; }.movie-app h1 { margin-bottom: 16px; font-size: 1.5rem; }.movie-app input { width: 100%; padding: 10px 12px; border-radius: 6px; border: 1px solid #3a3f4b; background: #23262e; color: #e8e8e8; font-size: 1rem; margin-bottom: 16px; }.status { color: #9aa4b2; padding: 12px 0; }.status.error { color: #ff6b6b; }.movie-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px; }.movie-card { background: #23262e; border-radius: 8px; overflow: hidden; }.movie-card img { width: 100%; display: block; }.movie-card h3 { font-size: 0.85rem; padding: 8px 8px 2px; }.movie-card p { font-size: 0.75rem; color: #9aa4b2; padding: 0 8px 8px; }// App.jsximport { useState, useEffect } from 'react';
function App() { const [query, setQuery] = useState(''); const [movies, setMovies] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null);
useEffect(() => { if (query.trim() === '') { setMovies([]); setError(null); return; }
const controller = new AbortController();
const timeoutId = setTimeout(async () => { setLoading(true); setError(null);
try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(query); const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error('Search request failed.');
const data = await response.json(); setMovies(data.results); } catch (err) { if (err.name !== 'AbortError') { setError(err.message); setMovies([]); } } finally { setLoading(false); } }, 400);
return () => { clearTimeout(timeoutId); controller.abort(); }; }, [query]);
return ( <div className="movie-app"> <h1>Movie Search</h1> <input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search for a movie..." />
{loading && <p className="status">Loading...</p>} {error && <p className="status error">{error}</p>} {!loading && !error && query.trim() !== '' && movies.length === 0 && ( <p className="status">No movies found for "{query}".</p> )}
<div className="movie-grid"> {movies.map((movie) => ( <div className="movie-card" key={movie.trackId}> <img src={movie.artworkUrl100} alt={movie.trackName} /> <h3>{movie.trackName}</h3> <p>{movie.releaseDate ? movie.releaseDate.slice(0, 4) : 'Unknown year'}</p> </div> ))} </div> </div> );}
export default App;The live preview below cannot reliably reach an external API from inside a sandboxed iframe, so it swaps the real `fetch` call for a small local dataset and a simulated delay — everything else (the debounce, the loading/error/empty states, the grid) behaves identically to the real, network-connected version above.
Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a "Load More" button that requests the next page of results and appends them to the existing `movies` array.
- Add a genre or media-type dropdown (movie / tv show / music) that becomes part of the query string.
- Click a card to open a detail view with the movie's longer description, fetched from `movie.trackViewUrl` or a second endpoint.
- Save searched movies to a "favorites" list in `localStorage`, reusing the persistence pattern from the Todo App project.
- Cache recent search results in a `useRef` map so re-searching the same term shows results instantly without a new request.
Summary
You built a real, network-connected search feature using `useEffect` to synchronize component state with an external API, `setTimeout` cleanup to debounce rapid input, and `AbortController` to prevent stale responses from overwriting fresh ones. This exact shape — debounce, abort-on-cleanup, loading/error/empty conditional rendering — is the pattern you will reach for in almost any React component that searches or fetches data as the user types.