Building a Movie App with Clean Architecture Concepts in React Native
Rubem Vasconcelos

Rubem Vasconcelos @rubemfsv

About: Software engineer holding a master’s degree in software engineering with focus in software architecture and contract testing, and with a bachelor’s degree in computer science focusing on clean code.

Location:
Maceió, Brazil
Joined:
Dec 9, 2020

Building a Movie App with Clean Architecture Concepts in React Native

Publish Date: Nov 25
1 0

I'm a huge movie fan, and I've decided to combine my passion for cinema with my love for building well-architected systems to practice my React Native skills. That's how Premiere Night was born.

I've worked with Clean Architecture before. In fact, I built another React Native project with even more layers and complexity, but it was on an older version of React Native. I wanted to challenge myself to apply these same architectural principles to a modern React Native 0.82 setup with the latest tools - in a more simple way.

But this isn't just a "look at my code" post. I made mistakes, learned valuable lessons, and discovered what actually works (and what's overkill) when building production-ready React Native apps. Whether you're a solo developer or working in a team, I hope my experience helps you make better architectural decisions.

What We're Building

Premiere Night is a movie discovery app that lets users:

  • Browse popular, top-rated, and upcoming movies
  • Search movies in real-time with debouncing (500ms)
  • View detailed movie information
  • Save favorites to an offline watchlist

Tech Stack:

  • React Native 0.82 (bare workflow, no Expo)
  • TypeScript 5.8 with strict mode
  • Redux Toolkit 2.10 for state management
  • React Navigation 7.x
  • AsyncStorage for local persistence
  • Jest + Testing Library (289 passing tests)

🎬 Try Android Demo | 📱 Try iOS Demo | 💻 View Source


Clean Architecture: Breaking It Down

If you've never worked with Clean Architecture before, don't worry! I'll explain it in a way that actually makes sense (I wish someone had done this for me when I started).

The core idea is simple: separate your app into layers where inner layers don't know about outer layers. Think of it like a building where the foundation doesn't care about the paint color on the walls.

If you want to dive deeper into Clean Architecture, I've written guides on the topic:

This post focuses specifically on React Native implementation, but those articles will give you the foundational knowledge if you're new to these concepts.

┌─────────────────────────────────────────┐
│       PRESENTATION LAYER                │
│  (UI, Screens, Components, Hooks)       │
└──────────────┬──────────────────────────┘
               ↓
┌─────────────────────────────────────────┐
│          DATA LAYER                     │
│  (Repositories, DTOs, Mappers)          │
└────────┬─────────────────────┬──────────┘
         ↓                     ↓
┌──────────────────┐  ┌──────────────────┐
│  DOMAIN LAYER    │  │  INFRA LAYER     │
│  (Entities,      │  │  (API, Redux,    │
│   Interfaces)    │  │   Storage)       │
└──────────────────┘  └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Layer Responsibilities

Domain Layer (Pure TypeScript):

  • Defines business entities (Movie, MovieDetails)
  • Defines repository interfaces (IMovieRepository)
  • Zero external dependencies
  • Framework-agnostic

Infrastructure Layer:

  • HTTP client (TMDb API)
  • Redux store configuration
  • State slices (movies, search, watchlist, details)
  • AsyncStorage integration

Data Layer:

  • Repository implementations (MovieRepositoryImpl)
  • DTOs matching API responses
  • Mappers transforming DTOs to domain entities
  • Data utilities (image URLs, etc.)

Presentation Layer:

  • React components and screens
  • Custom hooks for business logic
  • Navigation configuration
  • UI styles and themes

Layer Deep Dive

1. Domain Layer: Pure Business Logic

Purpose: Define what the app does, not how.

export interface Movie {
  id: number;
  title: string;
  overview: string;
  posterPath: string | null;
  releaseDate: string;
  voteAverage: number;
}

export interface IMovieRepository {
  getPopularMovies(params?: MovieListParams): Promise<PaginatedResponse<Movie>>;
  getMovieDetails(params: MovieDetailsParams): Promise<MovieDetails>;
  searchMovies(params: MovieSearchParams): Promise<PaginatedResponse<Movie>>;
}
Enter fullscreen mode Exit fullscreen mode

Key Benefits:

  • No React, no Redux, no external libraries
  • Easy to understand business rules
  • Reusable across platforms (web, mobile)
  • Perfect for documentation

2. Infrastructure Layer: External Systems

Purpose: Handle communication with external dependencies.

class ApiClient {
  private baseURL: string;
  private apiKey: string;

  async get<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
    const url = this.buildUrl(endpoint, params);
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`);
    }

    return response.json();
  }
}

export const apiClient = new ApiClient();
Enter fullscreen mode Exit fullscreen mode

Why Redux Toolkit?

  1. Excellent TypeScript support with full type inference
  2. Less boilerplate (no action constants, simplified reducers)
  3. Built-in thunk middleware for async actions
  4. Immer integration for immutable updates

3. Data Layer: Transformation Hub

Purpose: Transform external data to domain models.

import { IMovieRepository } from '@domain/repositories/MovieRepository';
import { apiClient } from '@infra/api/ApiClient';
import { MovieMapper } from '../mappers/MovieMapper';

export class MovieRepositoryImpl implements IMovieRepository {
  async getPopularMovies(
    params?: MovieListParams
  ): Promise<PaginatedResponse<Movie>> {
    // 1. Fetch from API
    const response = await apiClient.get<PaginatedResponseDTO<MovieDTO>>(
      'movie/popular',
      { page: params?.page, language: params?.language || 'en-US' }
    );

    // 2. Transform DTO to Domain
    return MovieMapper.toPaginatedDomain(response, MovieMapper.toDomain);
  }
}

export const movieRepository = new MovieRepositoryImpl();
Enter fullscreen mode Exit fullscreen mode

The Mapper Pattern:

export class MovieMapper {
  static toDomain(dto: MovieDTO): Movie {
    return {
      id: dto.id,
      title: dto.title,
      overview: dto.overview,
      posterPath: dto.poster_path, // snake_case → camelCase
      releaseDate: dto.release_date,
      voteAverage: dto.vote_average,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Why Mappers?

  • API contracts change, domain models don't
  • Centralized transformation logic
  • Easy to test in isolation
  • Type-safe conversions

4. Presentation Layer: User Interface

Custom Hooks Pattern:

import { useAppDispatch, useAppSelector } from '@infra/store/hooks';
import { fetchPopularMovies, selectMoviesState } from '@infra/store/movies';

export const useMovies = () => {
  const dispatch = useAppDispatch();
  const moviesState = useAppSelector(selectMoviesState);

  useEffect(() => {
    dispatch(fetchPopularMovies({ page: 1 }));
  }, [dispatch]);

  const loadMore = useCallback(() => {
    dispatch(fetchPopularMovies({ 
      page: moviesState.popular.currentPage + 1, 
      append: true 
    }));
  }, [dispatch, moviesState.popular.currentPage]);

  return {
    movies: moviesState.popular.data,
    loading: moviesState.popular.loading,
    error: moviesState.popular.error,
    loadMore,
  };
};
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Components focus solely on rendering
  • Business logic is reusable and testable
  • Easy to mock in tests
  • Hooks can be tested independently of UI

Implementing a Feature: Movie Search

Let's implement the search feature step-by-step to see the architecture in action.

Step 1: Define Domain Contract

export interface MovieSearchParams {
  query: string;
  page?: number;
  language?: string;
  year?: number;
}

export interface IMovieRepository {
  searchMovies(params: MovieSearchParams): Promise<PaginatedResponse<Movie>>;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement Repository

async searchMovies(
  params: MovieSearchParams
): Promise<PaginatedResponse<Movie>> {
  const response = await apiClient.get<PaginatedResponseDTO<MovieDTO>>(
    'search/movie',
    {
      query: params.query,
      page: params.page,
      language: params.language || 'en-US',
      year: params.year,
    }
  );

  return MovieMapper.toPaginatedDomain(response, MovieMapper.toDomain);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create Redux Slice

const searchSlice = createSlice({
  name: 'search',
  initialState: {
    query: '',
    results: [],
    loading: false,
    hasSearched: false,
  },
  reducers: {
    setSearchQuery: (state, action: PayloadAction<string>) => {
      state.query = action.payload;
    },
    clearSearch: (state) => {
      state.query = '';
      state.results = [];
      state.hasSearched = false;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(searchMovies.pending, (state) => {
        state.loading = true;
      })
      .addCase(searchMovies.fulfilled, (state, action) => {
        state.loading = false;
        state.results = action.payload.results;
        state.hasSearched = true;
      });
  },
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Create Custom Hook with Debounce

This is where the magic happens - debouncing reduces API calls by ~80%.

export const useMovieSearch = (debounceTime = 500) => {
  const dispatch = useAppDispatch();
  const searchState = useAppSelector(selectSearchState);
  const [localQuery, setLocalQuery] = useState('');
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const handleQueryChange = useCallback((query: string) => {
    setLocalQuery(query); // Immediate UI update
    dispatch(setSearchQuery(query));

    if (debounceTimer.current) {
      clearTimeout(debounceTimer.current);
    }

    if (query.trim().length === 0) {
      dispatch(clearSearch());
      return;
    }

    // Debounced API call
    if (query.trim().length >= 2) {
      debounceTimer.current = setTimeout(() => {
        dispatch(searchMovies({ query: query.trim(), page: 1 }));
      }, debounceTime);
    }
  }, [dispatch, debounceTime]);

  useEffect(() => {
    return () => {
      if (debounceTimer.current) {
        clearTimeout(debounceTimer.current);
      }
    };
  }, []);

  return {
    query: localQuery,
    results: searchState.results,
    loading: searchState.loading,
    onQueryChange: handleQueryChange,
  };
};
Enter fullscreen mode Exit fullscreen mode

Why Debounce?

  • Reduces API calls by ~80% during typing
  • Better performance and UX
  • Respects TMDb API rate limits
  • Immediate visual feedback, delayed API call

Step 5: Use in Component

export const HomeScreen = () => {
  const { query, results, loading, onQueryChange } = useMovieSearch();

  return (
    <View>
      <SearchBar 
        value={query}
        onChangeText={onQueryChange}
        placeholder="Search movies..."
      />
      {loading && <ActivityIndicator />}
      {results.length > 0 && <SearchResults movies={results} />}
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

Why This Architecture Changed My Life (Okay, My Codebase)

Let me be honest, when I started, I thought "this is way too much work". But after finishing the project, I can clearly see the benefits. Here's what I gained:

1. Testing Became More Enjoyable

Each layer can be tested independently, which means:

  • No more "change one thing, break everything" moments
  • Tests run faster because you're not booting up the whole app
  • 99.77% coverage wasn't painful—it was achievable!
// Test mapper without API
describe('MovieMapper', () => {
  it('should map DTO to domain', () => {
    const dto: MovieDTO = {
      id: 1,
      title: 'Test Movie',
      poster_path: '/test.jpg',
    };

    const result = MovieMapper.toDomain(dto);

    expect(result).toEqual({
      id: 1,
      title: 'Test Movie',
      posterPath: '/test.jpg',
    });
  });
});

// Test repository with mocked API
jest.mock('@infra/api/ApiClient');

describe('MovieRepositoryImpl', () => {
  it('should fetch popular movies', async () => {
    const mockResponse = { results: [/* ... */] };
    (apiClient.get as jest.Mock).mockResolvedValue(mockResponse);

    const result = await movieRepository.getPopularMovies();

    expect(apiClient.get).toHaveBeenCalledWith('movie/popular', expect.any(Object));
    expect(result.results).toHaveLength(mockResponse.results.length);
  });
});

// Test hook without rendering UI
const { result } = renderHook(() => useMovieSearch(), {
  wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});

act(() => {
  result.current.onQueryChange('Avatar');
});

await waitFor(() => {
  expect(result.current.loading).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

Test Distribution:

  • Unit tests: ~180 (Mappers, utils, reducers)
  • Integration tests: ~60 (Redux slices with thunks)
  • Component tests: ~49 (React components and hooks)

2. Maintainability

  • Clear boundaries: Each layer has a single responsibility
  • Easy to find code: Predictable folder structure
  • Type-safe refactoring: TypeScript catches breaking changes
  • Self-documenting: Interfaces serve as documentation

3. Flexibility

Want to switch from Redux to Zustand?

  • Only change the Infra layer
  • Domain and Data layers stay the same
  • Components use the same custom hooks

Want to add GraphQL?

  • Create new repository implementation
  • Keep domain entities unchanged
  • Swap implementations via dependency injection

4. Scalability

Adding new features is straightforward:

  1. Define domain contract
  2. Implement repository
  3. Create Redux slice (if needed)
  4. Build UI components

Each step is independent and testable.


Let's Talk About The Elephant in The Room

Is this overkill? Sometimes, yes. Let me be brutally honest about the downsides:

1. You'll Create A LOT of Files

Real talk:

  • I spent 30% more time on setup than a simpler approach
  • For every feature, I created DTOs, Mappers, Interfaces, Tests...

But, in my opinion, here's when it's worth it:

  • Medium to large projects
  • Team of 2+ developers
  • Long-term maintenance expected
  • High test coverage required

When It's Overkill:

  • Quick prototypes
  • Simple CRUD apps
  • Solo weekend projects

2. Learning Curve

Team members need to understand:

  • Layer boundaries and dependencies
  • Repository pattern
  • DTO vs Domain entities
  • When to create new abstractions

Solution:

  • Good documentation (like ARCHITECTURE.md)
  • Code reviews to enforce patterns
  • Pair programming for onboarding

3. Initial Overhead

First feature takes longer:

  • Set up folder structure
  • Configure TypeScript path aliases
  • Create base interfaces
  • Set up testing infrastructure

But subsequent features are faster:

  • Copy-paste and adapt existing patterns
  • Reuse mappers, utilities, components
  • Clear guidelines reduce decision fatigue

Key Concepts Explained

Repository Pattern

What: Interface that abstracts data access logic.

Why:

  • Decouple business logic from data source
  • Easy to mock in tests
  • Can swap implementations (REST → GraphQL)

Example:

// Domain defines what we need
interface IMovieRepository {
  getPopularMovies(): Promise<Movie[]>;
}

// Data implements how we get it
class MovieRepositoryImpl implements IMovieRepository {
  async getPopularMovies(): Promise<Movie[]> {
    const response = await apiClient.get('movie/popular');
    return response.results.map(MovieMapper.toDomain);
  }
}

// Tests can use a fake implementation
class FakeMovieRepository implements IMovieRepository {
  async getPopularMovies(): Promise<Movie[]> {
    return [{ id: 1, title: 'Test Movie', /* ... */ }];
  }
}
Enter fullscreen mode Exit fullscreen mode

DTO (Data Transfer Object)

What: Object that matches external API shape.

Why:

  • API uses snake_case, we use camelCase
  • API might change, our domain shouldn't
  • Separates external contract from internal model

Example:

// DTO: Matches API response
interface MovieDTO {
  id: number;
  title: string;
  poster_path: string | null; // snake_case from API
  vote_average: number;
}

// Domain: Our internal model
interface Movie {
  id: number;
  title: string;
  posterPath: string | null; // camelCase for TypeScript
  voteAverage: number;
}

// Mapper bridges the gap
MovieMapper.toDomain(dto);
Enter fullscreen mode Exit fullscreen mode

Redux Slice Pattern

What: Co-locate all Redux logic for a feature in one place.

Structure:

store/movies/
├── slice.ts       # Reducers
├── thunks.ts      # Async actions
├── selectors.ts   # State queries
├── initialState.ts
└── tests/
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • All related code in one place
  • Easy to find and modify
  • Clear feature boundaries
  • Simple to test

Custom Hooks for Business Logic

What: Extract component logic into reusable hooks.

Why:

  • Components focus on rendering
  • Logic is reusable across components
  • Easy to test without mounting components
  • Better separation of concerns

Example:

// Hook handles business logic
const useMovies = () => {
  const dispatch = useAppDispatch();
  const movies = useAppSelector(selectPopularMovies);

  useEffect(() => {
    dispatch(fetchPopularMovies());
  }, []);

  return { movies };
};

// Component just renders
const MovieList = () => {
  const { movies } = useMovies();
  return <FlatList data={movies} />;
};
Enter fullscreen mode Exit fullscreen mode

Results: By The Numbers

  • 99.77% Statement Coverage
  • 98.41% Branch Coverage
  • 100% Function Coverage
  • 289 Passing Tests
  • 4 Clear Layers
  • 0 Circular Dependencies

Questions You Might Be Asking

Q: "Is this overkill for my 5-screen app?"

A: Yes, probably. For small projects (< 10 screens), consider:

  • Simple folder structure (components, screens, utils)
  • React Query instead of Redux
  • Fewer abstractions

Use Clean Architecture when:

  • Team size > 2
  • Expected lifetime > 6 months
  • Complexity will grow
  • High test coverage required

Q: "Does this work with Expo?"

A: Absolutely! I used bare React Native, but the architecture doesn't care. Just:

  • Swap @react-native-async-storage with expo-secure-store if you want
  • Use Expo modules wherever you need them
  • Keep the same layer structure

The beauty of Clean Architecture is that it's framework-agnostic!

Q: "How do you manage API keys securely?"

A: I use react-native-dotenv:

import { TMDB_API_KEY, TMDB_BASE_URL } from '@env';

const apiClient = new ApiClient({
  baseURL: TMDB_BASE_URL,
  apiKey: TMDB_API_KEY,
});
Enter fullscreen mode Exit fullscreen mode

When to Use This Architecture

Great For:

  • Production apps
  • Team projects
  • Apps with complex business logic
  • High test coverage requirements
  • Long-term maintenance
  • Multiple data sources

Overkill For:

  • Quick prototypes
  • Solo weekend projects
  • Simple CRUD apps
  • Learning projects
  • MVPs that might be thrown away

Conclusion

Building this app showed me that Clean Architecture isn’t about achieving perfection — it’s about making deliberate, well-reasoned trade-offs. What initially felt overwhelming gradually revealed its value. The test suite reached 99.77% coverage and, instead of feeling unattainable, it became a natural part of the workflow. Adding new features turned into a predictable, almost effortless process once the foundation was in place. Most importantly, refactoring stopped being risky—the architecture and tests consistently supported every change.


Resources

Premiere Night Project:

Learn More About Clean Architecture:

Comments 0 total

    Add comment