Overview
A movie search app is the project where `watch` really earns its place, because it has to synchronize a component with something outside Vue'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.
The fetching logic is built as a `useMovieSearch` composable — Vue's equivalent of a custom React hook — so the search-term-to-results pipeline is fully reusable and testable independent of any one component's template. The composable 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 Vue instead of authentication setup.
- A controlled search input backed by `ref` and `v-model`.
- A `useMovieSearch(query)` composable that fetches movies whenever the query ref changes.
- A debounce (via `setTimeout`, cleared on the next change) 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 `movies` refs, each driving its own conditionally-rendered piece of UI.
- A responsive grid of movie cards built from the live API response.
Prerequisites
- Template syntax, Single File Components, and `ref` — 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 `v-if`/`v-else-if`/`v-else`.
- `watch`, composables, and `AbortController` are new here — every one is introduced from scratch in this tutorial.
Project Structure
All of the interesting logic — when the fetch happens, and what the four possible outcomes (idle, loading, error, results) look like — lives in one composable, which keeps `App` itself small: it just owns the `query` ref, calls `useMovieSearch(query)`, and renders whatever comes back.
src/ composables/ useMovieSearch.js // Debounced, cancellable fetch logic — the reusable core of the project App.vue // Search input + conditional rendering, driven entirely by the composable style.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
`query` is the one piece of state `App` owns directly — everything else (`movies`, `loading`, `error`) is going to come back from the composable in Step 2. `v-model` keeps `query` and the input's visible text permanently in sync.
<!-- App.vue --><script setup>import { ref } from 'vue';
const query = ref(''); // exactly what's currently typed in the search box</script>
<template> <div class="movie-app"> <h1>Movie Search</h1> <input v-model="query" type="text" placeholder="Search for a movie..." /> <!-- Loading / error / results rendering is added in Steps 5 and 6 --> </div></template>Step 2: Build the useMovieSearch Composable
A composable takes reactive input (here, the `query` ref itself, not its unwrapped value) and returns reactive output — `App` will call `useMovieSearch(query)` and get back `movies`/`loading`/`error` refs whose values update automatically. Passing the ref itself (not `query.value`) is what lets the composable set up its own `watch` internally.
// composables/useMovieSearch.jsimport { ref, watch } from 'vue';
export function useMovieSearch(query) { const movies = ref([]); const loading = ref(false); const error = ref(null);
// watch(query, ...) re-runs this callback every time query.value changes — // v-model on the input in App.vue is what drives those changes watch(query, (newQuery) => { if (newQuery.trim() === '') { movies.value = []; error.value = null; return; }
async function fetchMovies() { loading.value = true; error.value = null;
try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(newQuery); // 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(); movies.value = data.results; } catch (err) { error.value = err.message; movies.value = []; } finally { loading.value = false; // runs whether the request succeeded or failed } }
fetchMovies(); });
// The composable hands back reactive refs — App.vue reads these the same // way it would read its own local ref() state return { movies, loading, error };}This 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 Inside the Watcher
A debounce waits for a pause in typing before actually doing the work. Because the previous version's `watch` callback ran the fetch immediately, a variable declared *outside* the callback (so it survives between calls) is needed to hold the pending timer — each new call to the callback clears whatever the last one scheduled before starting its own, which is exactly what turns a delay into a debounce.
// composables/useMovieSearch.js (debounced)import { ref, watch } from 'vue';
export function useMovieSearch(query) { const movies = ref([]); const loading = ref(false); const error = ref(null);
// Declared outside the watch callback so it persists across calls — // each new keystroke can see and clear the PREVIOUS keystroke's timer let timeoutId = null;
watch(query, (newQuery) => { if (timeoutId) clearTimeout(timeoutId); // cancel whatever the previous keystroke scheduled
if (newQuery.trim() === '') { movies.value = []; error.value = null; return; }
// Wait 400ms after the last keystroke before actually searching timeoutId = setTimeout(async () => { loading.value = true; error.value = null; try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(newQuery); const response = await fetch(url); if (!response.ok) throw new Error('Search request failed.'); const data = await response.json(); movies.value = data.results; } catch (err) { error.value = err.message; movies.value = []; } finally { loading.value = false; } }, 400); });
return { movies, loading, error };}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`, tracked the same way `timeoutId` is, lets the next keystroke cancel the in-flight request itself, not just the timer that was going to start it.
// composables/useMovieSearch.js (final)import { ref, watch } from 'vue';
export function useMovieSearch(query) { const movies = ref([]); const loading = ref(false); const error = ref(null);
let timeoutId = null; let controller = null; // tracks the AbortController for the most recent request
watch(query, (newQuery) => { if (timeoutId) clearTimeout(timeoutId); if (controller) controller.abort(); // cancel a still-in-flight request from an older, now-stale search term
if (newQuery.trim() === '') { movies.value = []; error.value = null; return; }
timeoutId = setTimeout(async () => { controller = new AbortController(); // a fresh controller tied to THIS specific request loading.value = true; error.value = null;
try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(newQuery); 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(); movies.value = data.results; } catch (err) { // A cancelled fetch rejects with an AbortError — that's expected // behavior from the debounce, not a real failure, so it's // deliberately NOT shown to the user as an error if (err.name !== 'AbortError') { error.value = err.message; movies.value = []; } } finally { loading.value = false; } }, 400); });
return { movies, loading, error };}Step 5: Render Loading, Error, and Empty States
Each of the four states — loading, error, no-results, and idle — is checked with `v-if`/`v-else-if`/`v-else`, so exactly one of them ever renders at a time.
<template> <p v-if="loading" class="status">Loading...</p> <p v-else-if="error" class="status error">{{ error }}</p> <p v-else-if="query.trim() !== '' && movies.length === 0" class="status"> No movies found for "{{ query }}". </p></template>Step 6: Render the Movie Grid
Once `movies` has results, `v-for` 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.
<template> <div class="movie-grid"> <div class="movie-card" v-for="movie in movies" :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></template>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; }// composables/useMovieSearch.jsimport { ref, watch } from 'vue';
export function useMovieSearch(query) { const movies = ref([]); const loading = ref(false); const error = ref(null);
let timeoutId = null; let controller = null;
watch(query, (newQuery) => { if (timeoutId) clearTimeout(timeoutId); if (controller) controller.abort();
if (newQuery.trim() === '') { movies.value = []; error.value = null; return; }
timeoutId = setTimeout(async () => { controller = new AbortController(); loading.value = true; error.value = null;
try { const url = 'https://itunes.apple.com/search?media=movie&limit=12&term=' + encodeURIComponent(newQuery); const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error('Search request failed.'); const data = await response.json(); movies.value = data.results; } catch (err) { if (err.name !== 'AbortError') { error.value = err.message; movies.value = []; } } finally { loading.value = false; } }, 400); });
return { movies, loading, error };}<!-- App.vue --><script setup>import { ref } from 'vue';import { useMovieSearch } from './composables/useMovieSearch';
const query = ref('');const { movies, loading, error } = useMovieSearch(query);</script>
<template> <div class="movie-app"> <h1>Movie Search</h1> <input v-model="query" type="text" placeholder="Search for a movie..." />
<p v-if="loading" class="status">Loading...</p> <p v-else-if="error" class="status error">{{ error }}</p> <p v-else-if="query.trim() !== '' && movies.length === 0" class="status"> No movies found for "{{ query }}". </p>
<div class="movie-grid"> <div class="movie-card" v-for="movie in movies" :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></template>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 composable shape, 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 `movies.value`.
- 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 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 plain (non-reactive) `Map` inside the composable so re-searching the same term shows results instantly without a new request.
Summary
You built a real, network-connected search feature packaged as a `useMovieSearch` composable, using `watch` to synchronize component state with an external API, a manually-tracked timer to debounce rapid input, and `AbortController` to prevent stale responses from overwriting fresh ones. This exact shape — a composable wrapping debounce, abort-on-next-change, and loading/error/empty state — is the pattern you will reach for in almost any Vue component that searches or fetches data as the user types.