LearnAI ToolsCareerPractice BuildsPlayContact
Vue.jsIntermediate~2.5 hours

Movie Search App

Search and display movies from an API with loading and error states.

ComposablesFetching DataWatchers

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.

What You'll Build
  • 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.js
import { 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.js
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;
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.

Live Preview

Sample Run

Sample Interaction

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.