LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.