React useEffect to fetch data not finished yet when try to sort data using React Router useSearchParams

Viewed 112

I have a React component that try to load movies first from tmdb API. Then, I create a button to sort the movies using query params. I want to use query params because I want to preserve the URL so that I can share the URL with sort value. (This is for my learning purpose also).

When the page is already rendered and I click the button, it's work fine.

But when I try to reload the page with query params, the query params useEffect is run first before the movies finished loaded.

Here is my code:

import { Box, Button } from '@mui/material';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';

import tmdb from '../apis/tmdb';
import MovieCard from '../components/MovieCard';

const MovieList = () => {
    const [queryParams, setQueryParams] = useSearchParams();
    const [movies, setMovies] = useState([]);

    useEffect(() => {
        const fetchMovies = async () => {
            try {
                const fetchedMovies = await tmdb.get("trending/movie/week");
                setMovies(fetchedMovies.data.results);
            } catch (error) {
                console.log(error);
            }
        }

        fetchMovies();
    }, []);

    useEffect(() => {
        const sortMovies = (type) => {
            if (type === 'asc') {
                const sorted = movies.sort((a, b) => a.vote_average - b.vote_average);
                setMovies(sorted);
            }
            if (type === 'desc') {
                const sorted = movies.sort((a, b) => b.vote_average - a.vote_average);
                setMovies(sorted);
            }
        }

        sortMovies(queryParams.get('sort'));
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [queryParams]);

    const setSortParam = (type) => {
        queryParams.set("sort", type);
        setQueryParams(queryParams);
    }

    return (
        <Box sx={{
            display: 'flex',
            flexDirection: 'column',
            mt: 5,
        }}>
            <Box sx={{
                mt: 5,
                display: 'flex',
                flexDirection: 'row',
                justifyContent: 'flex-end',
                alignItems: 'center',
            }}>
                Sort by Rating
                <Button
                    variant="contained"
                    sx={{ ml: 2 }}
                    onClick={() => setSortParam("asc")}
                >
                    Asc
                </Button>
                <Button
                    variant="contained"
                    sx={{ ml: 2, mr: 2 }}
                    onClick={() => setSortParam("desc")}
                >
                    Desc
                </Button>
            </Box>
            <Box sx={{
                display: 'flex',
                flexDirection: 'row',
                flexWrap: 'wrap',
                justifyContent: 'space-between',
            }}>
                {
                    movies.map(movie => (
                        <MovieCard key={movie.title} movie={movie}></MovieCard>
                    ))
                }
            </Box>
        </Box>
    );
}

export default MovieList;

I try to create new state to track if the movies length > 0, but sometimes it's buggy. I also try to useRef to track if the movies length > 0 but it's not working.

Is there anyway to wait for first useEffect to finish? Or should I use other approach than useEffect to read query params and sort?

2 Answers

I think you need a state to track if the movies has been loaded

const [moviesHasLoaded, setMoviesHasLoaded] = useState(false);

useEffect(() => {
    const fetchMovies = async () => {
        try {
            const fetchedMovies = await tmdb.get("trending/movie/week");
            setMovies(fetchedMovies.data.results);
            setMoviesHasLoaded(true);
        } catch (error) {
            console.log(error);
        }
    }

    fetchMovies();
}, []);

useEffect(() => {
    if (!moviesHasLoaded) return;
    const sortMovies = (type) => {
        if (type === 'asc') {
            const sorted = movies.sort((a, b) => a.vote_average - b.vote_average);
            setMovies(sorted);
        }
        if (type === 'desc') {
            const sorted = movies.sort((a, b) => b.vote_average - a.vote_average);
            setMovies(sorted);
        }
    }

    sortMovies(queryParams.get('sort'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryParams, moviesHasLoaded]);

I think you are trying to merge the movies state (i.e. the source of truth) with the derived state (i.e. the sorting order of movies). Derived state generally doesn't belong in state. You can easily derive the sorted movies array from the movies state and the current sort queryString parameter value.

Example:

import { Box, Button } from "@mui/material";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";

import tmdb from "../apis/tmdb";
import MovieCard from "../components/MovieCard";

// Sort comparators
const sortVoteAverageAsc = (a, b) => a.vote_average - b.vote_average;
const sortVoteAverageDesc = (a, b) => sortVoteAverageAsc(b, a);

// Sorting utility function
const sortVoteAverage = (sort = "asc") =>
  sort === 'asc' ? sortVoteAverageAsc : sortVoteAverageDesc;

const MovieList = () => {
  const [queryParams, setQueryParams] = useSearchParams();
  const [movies, setMovies] = useState([]);

  useEffect(() => {
    const fetchMovies = async () => {
      try {
        const fetchedMovies = await tmdb.get("trending/movie/week");
        setMovies(fetchedMovies.data.results);
      } catch (error) {
        console.log(error);
      }
    };

    fetchMovies();
  }, []);

  const setSortParam = (type) => {
    queryParams.set("sort", type);
    setQueryParams(queryParams);
  };

  // create shallow copy of movies state, then sort by sort query parameter
  const sortedMovies = movies
    .slice()
    .sort(sortVoteAverage(queryParams.get("sort")));

  return (
    <Box
      ...
    >
      <Box
        ...
      >
        Sort by Rating
        <Button
          variant="contained"
          sx={{ ml: 2 }}
          onClick={() => setSortParam("asc")}
        >
          Asc
        </Button>
        <Button
          variant="contained"
          sx={{ ml: 2, mr: 2 }}
          onClick={() => setSortParam("desc")}
        >
          Desc
        </Button>
      </Box>
      <Box
        sx={{
          display: "flex",
          flexDirection: "row",
          flexWrap: "wrap",
          justifyContent: "space-between"
        }}
      >
        {sortedMovies.map((movie) => (
          <MovieCard key={movie.title} movie={movie} />
        ))}
      </Box>
    </Box>
  );
};

If needed, you can memoize the sortedMovies value using the useMemo hook with a dependency on the movies state and the current sort queryString parameter value.

Example:

const sort = queryParams.get("sort");

const sortedMovies = useMemo(() => {
  return movies
    .slice()
    .sort(sortVoteAverage(sort))
}, [movies, sort]);
Related