Back to Blog

React Movie Search App Using API

Build a complete movie search application with React, featuring search, detail modals, favorites, and responsive design using the OMDB API.

React Movie Search App Using API

A hands-on tutorial building a full-featured movie search app with React and the OMDB API

1. What We're Building

In this tutorial, we'll build a complete movie search application using React. The app will allow users to search for movies by title, view detailed information in a modal, save favorites to localStorage, and enjoy a fully responsive layout.

Features

  • Search bar with debounce to avoid excessive API calls
  • Movie cards grid displaying poster, title, year, and type
  • Detail modal showing plot, ratings, cast, and runtime
  • Favorites system persisted in localStorage
  • Loading & error states with spinners and messages
  • Responsive design that works on mobile, tablet, and desktop

Prerequisites: You should have basic knowledge of React hooks (useState, useEffect) and how to work with APIs. Node.js and npm should be installed on your machine.

2. Getting an API Key

We'll use the OMDB (Open Movie Database) API to fetch movie data. It's free for basic usage and provides all the data we need.

Steps to Get Your API Key

  1. Go to http://www.omdbapi.com/apikey.aspx
  2. Select the FREE tier (1,000 requests per day)
  3. Enter your email and click Submit
  4. Check your email and click the activation link
  5. Your API key will be displayed — copy it

Setting Up Environment Variables

Create a .env file in your project root:

VITE_OMDB_API_KEY=your_api_key_here

Security: Never commit your API key to version control. Add .env to your .gitignore file. For production, use a backend proxy to hide your key.

In Vite, environment variables are accessed via import.meta.env.VITE_OMDB_API_KEY. This is different from Create React App which uses process.env.REACT_APP_.

3. Project Setup

We'll scaffold our project using Vite for fast development and optimized builds.

Create the Project

npm create vite@latest movie-search-app -- --template react
cd movie-search-app
npm install

Install Dependencies

npm install axios

We'll use Axios for HTTP requests because it provides automatic JSON transformation, request/response interceptors, and better error handling than the native fetch API.

Project Structure

movie-search-app/
├── src/
│   ├── components/
│   │   ├── SearchBar.jsx
│   │   ├── MovieCard.jsx
│   │   ├── MovieGrid.jsx
│   │   ├── MovieModal.jsx
│   │   ├── Favorites.jsx
│   │   └── Spinner.jsx
│   ├── hooks/
│   │   └── useDebounce.js
│   ├── utils/
│   │   └── api.js
│   ├── App.jsx
│   ├── App.css
│   └── main.jsx
├── .env
└── package.json

Tip: Organizing your components and hooks into separate folders from the start keeps your project maintainable as it grows.

4. Building the Search Component

The search component is the entry point of our app. We'll create a controlled input with debounce functionality to prevent unnecessary API calls on every keystroke.

Creating the useDebounce Hook

// src/hooks/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;
}

SearchBar Component

// src/components/SearchBar.jsx
import { useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';

export default function SearchBar({ onSearch }) {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (query.trim()) {
      onSearch(query.trim());
    }
  };

  // Trigger search when debounced value changes
  useEffect(() => {
    if (debouncedQuery) {
      onSearch(debouncedQuery);
    }
  }, [debouncedQuery, onSearch]);

  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Search for movies..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="search-input"
      />
      <button type="submit" className="search-btn">
        <i className="fas fa-search"></i>
      </button>
    </form>
  );
}

The debounce hook delays the search by 500ms after the user stops typing, reducing API calls significantly while maintaining a responsive feel.

5. Fetching Movie Data

Now let's create the API utility and wire up data fetching in our main App component.

API Utility

// src/utils/api.js
import axios from 'axios';

const API_KEY = import.meta.env.VITE_OMDB_API_KEY;
const BASE_URL = 'https://www.omdbapi.com/';

const api = axios.create({
  baseURL: BASE_URL,
  params: {
    apikey: API_KEY,
  },
});

export const searchMovies = async (query, page = 1) => {
  const response = await api.get('/', {
    params: { s: query, page, type: 'movie' },
  });
  return response.data;
};

export const getMovieDetails = async (imdbID) => {
  const response = await api.get('/', {
    params: { i: imdbID, plot: 'full' },
  });
  return response.data;
};

App Component with useEffect

// src/App.jsx
import { useState, useEffect, useCallback } from 'react';
import SearchBar from './components/SearchBar';
import MovieGrid from './components/MovieGrid';
import MovieModal from './components/MovieModal';
import Spinner from './components/Spinner';
import { searchMovies } from './utils/api';
import './App.css';

function App() {
  const [movies, setMovies] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [selectedMovie, setSelectedMovie] = useState(null);
  const [favorites, setFavorites] = useState(() => {
    const saved = localStorage.getItem('favorites');
    return saved ? JSON.parse(saved) : [];
  });

  const handleSearch = useCallback(async (query) => {
    setLoading(true);
    setError(null);
    try {
      const data = await searchMovies(query);
      if (data.Response === 'True') {
        setMovies(data.Search);
      } else {
        setError(data.Error);
        setMovies([]);
      }
    } catch (err) {
      setError('Failed to fetch movies. Please try again.');
      setMovies([]);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    localStorage.setItem('favorites', JSON.stringify(favorites));
  }, [favorites]);

  return (
    <div className="app">
      <header className="app-header">
        <h1>🎬 Movie Search</h1>
        <SearchBar onSearch={handleSearch} />
      </header>

      <main>
        {loading && <Spinner />}
        {error && <p className="error-message">{error}</p>}
        {!loading && !error && (
          <MovieGrid
            movies={movies}
            onSelect={setSelectedMovie}
            favorites={favorites}
            onToggleFavorite={setFavorites}
          />
        )}
      </main>

      {selectedMovie && (
        <MovieModal
          movie={selectedMovie}
          onClose={() => setSelectedMovie(null)}
        />
      )}
    </div>
  );
}

export default App;

Key Concept: We use useCallback for the search handler to prevent unnecessary re-renders of child components. The finally block ensures loading state is always cleared.

6. Displaying Results

We'll create a grid layout for movie cards, each showing the poster, title, year, and type.

MovieCard Component

// src/components/MovieCard.jsx
export default function MovieCard({ movie, onSelect, isFavorite, onToggleFavorite }) {
  const handleFavoriteClick = (e) => {
    e.stopPropagation();
    onToggleFavorite(movie);
  };

  return (
    <div className="movie-card" onClick={() => onSelect(movie)}>
      <div className="movie-poster">
        <img
          src={movie.Poster !== 'N/A' ? movie.Poster : '/placeholder.png'}
          alt={movie.Title}
          loading="lazy"
        />
        <button
          className={`favorite-btn ${isFavorite ? 'active' : ''}`}
          onClick={handleFavoriteClick}
        >
          <i className={isFavorite ? 'fas fa-heart' : 'far fa-heart'}></i>
        </button>
      </div>
      <div className="movie-info">
        <h3 className="movie-title">{movie.Title}</h3>
        <p className="movie-year">{movie.Year} • {movie.Type}</p>
      </div>
    </div>
  );
}

MovieGrid Component

// src/components/MovieGrid.jsx
import MovieCard from './MovieCard';

export default function MovieGrid({ movies, onSelect, favorites, onToggleFavorite }) {
  const toggleFavorite = (movie) => {
    const exists = favorites.find((fav) => fav.imdbID === movie.imdbID);
    if (exists) {
      onToggleFavorite(favorites.filter((fav) => fav.imdbID !== movie.imdbID));
    } else {
      onToggleFavorite([...favorites, movie]);
    }
  };

  return (
    <div className="movie-grid">
      {movies.map((movie) => (
        <MovieCard
          key={movie.imdbID}
          movie={movie}
          onSelect={onSelect}
          isFavorite={favorites.some((fav) => fav.imdbID === movie.imdbID)}
          onToggleFavorite={toggleFavorite}
        />
      ))}
    </div>
  );
}

CSS Grid Styling

.movie-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 1.5rem;
  padding: 2rem 0;
}

.movie-card {
  border-radius: 12px;
  overflow: hidden;
  background: #1a1a2e;
  cursor: pointer;
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.movie-card:hover {
  transform: translateY(-8px);
  box-shadow: 0 12px 40px rgba(99, 102, 241, 0.3);
}

.movie-poster img {
  width: 100%;
  aspect-ratio: 2/3;
  object-fit: cover;
}

Tip: Use loading="lazy" on images to defer loading off-screen posters. This significantly improves initial page load performance.

7. Movie Detail Modal

When a user clicks a movie card, we'll fetch full details and display them in a modal overlay.

// src/components/MovieModal.jsx
import { useState, useEffect } from 'react';
import { getMovieDetails } from '../utils/api';
import Spinner from './Spinner';

export default function MovieModal({ movie, onClose }) {
  const [details, setDetails] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchDetails = async () => {
      try {
        const data = await getMovieDetails(movie.imdbID);
        setDetails(data);
      } catch (err) {
        console.error('Failed to fetch details:', err);
      } finally {
        setLoading(false);
      }
    };
    fetchDetails();
  }, [movie.imdbID]);

  useEffect(() => {
    const handleEsc = (e) => {
      if (e.key === 'Escape') onClose();
    };
    document.addEventListener('keydown', handleEsc);
    document.body.style.overflow = 'hidden';
    return () => {
      document.removeEventListener('keydown', handleEsc);
      document.body.style.overflow = '';
    };
  }, [onClose]);

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        <button className="modal-close" onClick={onClose}>
          <i className="fas fa-times"></i>
        </button>

        {loading ? (
          <Spinner />
        ) : details ? (
          <div className="modal-body">
            <img src={details.Poster} alt={details.Title} className="modal-poster" />
            <div className="modal-info">
              <h2>{details.Title} ({details.Year})</h2>
              <div className="modal-meta">
                <span>⭐ {details.imdbRating}/10</span>
                <span>🕐 {details.Runtime}</span>
                <span>🎭 {details.Genre}</span>
              </div>
              <p className="modal-plot">{details.Plot}</p>
              <p><strong>Director:</strong> {details.Director}</p>
              <p><strong>Actors:</strong> {details.Actors}</p>
              <p><strong>Awards:</strong> {details.Awards}</p>
            </div>
          </div>
        ) : null}
      </div>
    </div>
  );
}

Accessibility: We handle Escape key to close the modal and use stopPropagation to prevent closing when clicking inside the modal content. The body scroll is locked while the modal is open.

8. Handling Errors & Loading States

A good user experience requires proper handling of loading states, errors, and empty results.

Spinner Component

// src/components/Spinner.jsx
export default function Spinner() {
  return (
    <div className="spinner-container">
      <div className="spinner"></div>
      <p>Loading...</p>
    </div>
  );
}

CSS for Spinner

.spinner-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 3rem;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid rgba(99, 102, 241, 0.2);
  border-top-color: #6366f1;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.error-message {
  text-align: center;
  color: #ef4444;
  padding: 2rem;
  font-size: 1.1rem;
}

Empty State

Add an empty state for when no results are found:

// Inside MovieGrid.jsx
if (movies.length === 0) {
  return (
    <div className="empty-state">
      <i className="fas fa-film"></i>
      <h3>No movies found</h3>
      <p>Try searching for a different movie title</p>
    </div>
  );
}

Always handle edge cases: The OMDB API returns a string "False" for no results and an error message. Make sure to check data.Response === 'True' before trying to access data.Search.

9. Adding Favorites

We'll implement a favorites system using localStorage to persist the user's saved movies across sessions.

Managing Favorites State

// In App.jsx - Initialize from localStorage
const [favorites, setFavorites] = useState(() => {
  try {
    const saved = localStorage.getItem('movie-favorites');
    return saved ? JSON.parse(saved) : [];
  } catch {
    return [];
  }
});

// Save to localStorage whenever favorites change
useEffect(() => {
  localStorage.setItem('movie-favorites', JSON.stringify(favorites));
}, [favorites]);

// Toggle favorite function
const toggleFavorite = (movie) => {
  setFavorites((prev) => {
    const exists = prev.find((fav) => fav.imdbID === movie.imdbID);
    if (exists) {
      return prev.filter((fav) => fav.imdbID !== movie.imdbID);
    }
    return [...prev, movie];
  });
};

Favorites Tab

// src/components/Favorites.jsx
export default function Favorites({ favorites, onSelect, onRemove }) {
  if (favorites.length === 0) {
    return (
      <div className="favorites-empty">
        <i className="far fa-heart"></i>
        <p>No favorites yet. Click the heart icon on any movie to save it here.</p>
      </div>
    );
  }

  return (
    <div className="favorites-section">
      <h2>Your Favorites ({favorites.length})</h2>
      <div className="movie-grid">
        {favorites.map((movie) => (
          <MovieCard
            key={movie.imdbID}
            movie={movie}
            onSelect={onSelect}
            isFavorite={true}
            onToggleFavorite={() => onRemove(movie.imdbID)}
          />
        ))}
      </div>
    </div>
  );
}

Performance: For larger datasets, consider using useReducer instead of multiple useState calls. It provides more predictable state transitions and is easier to debug.

10. Responsive Design

The final piece is making sure our app looks great on all screen sizes using CSS Grid and mobile-first design.

Mobile-First CSS

/* Base styles (mobile) */
.app {
  min-height: 100vh;
  background: #0f0f1a;
  color: #e2e8f0;
}

.app-header {
  text-align: center;
  padding: 2rem 1rem;
}

.search-bar {
  display: flex;
  max-width: 600px;
  margin: 1rem auto;
  border-radius: 50px;
  overflow: hidden;
  background: #1a1a2e;
}

.search-input {
  flex: 1;
  padding: 1rem 1.5rem;
  border: none;
  background: transparent;
  color: #fff;
  font-size: 1rem;
  outline: none;
}

.search-btn {
  padding: 1rem 1.5rem;
  background: #6366f1;
  color: white;
  border: none;
  cursor: pointer;
  transition: background 0.3s;
}

.search-btn:hover {
  background: #4f46e5;
}

/* Tablet */
@media (min-width: 768px) {
  .movie-grid {
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
    gap: 1.5rem;
    padding: 2rem;
  }

  .modal-body {
    display: flex;
    gap: 2rem;
  }

  .modal-poster {
    max-width: 300px;
  }
}

/* Desktop */
@media (min-width: 1024px) {
  .movie-grid {
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 2rem;
    max-width: 1200px;
    margin: 0 auto;
  }
}

Modal Responsiveness

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  padding: 1rem;
}

.modal-content {
  background: #1a1a2e;
  border-radius: 16px;
  max-width: 800px;
  width: 100%;
  max-height: 90vh;
  overflow-y: auto;
  position: relative;
}

@media (max-width: 640px) {
  .modal-body {
    flex-direction: column;
    align-items: center;
  }

  .modal-poster {
    max-width: 200px;
  }
}

Testing: Use Chrome DevTools' device toolbar to test your responsive design across different screen sizes. Pay special attention to the modal on mobile — it should be full-width with proper padding.

And that's it! You now have a fully functional movie search app built with React. The key takeaways from this project are working with external APIs, managing complex state, creating reusable components, and implementing responsive layouts. You can extend this further by adding features like movie trailers, user authentication, or comparing movies side by side.

Back to Top