Implementing Pagination in Your To-Do Application

Introduction to Implementing Pagination

Welcome to the next step in enhancing your to-do application! In this lesson, we will focus on implementing pagination. As your list of to-do items grows, it becomes essential to manage how they are displayed to ensure a smooth user experience. Pagination helps in breaking down the list into manageable chunks, making it easier for users to navigate through their tasks. By the end of this lesson, you'll be able to implement pagination in your application, providing a more organized and user-friendly interface.

What You'll Learn

In this lesson, you will learn how to add pagination to your to-do application using the Material-UI (MUI) Pagination component. This involves dividing your list of to-do items into pages and allowing users to navigate between them. Here's a quick look at how this can be integrated into the Todos.js file:

const Todos = () => {
  const [todos, setTodos] = useState([]); // State to store the list of todos
  const [error, setError] = useState(null); // State to store any error messages
  const [loading, setLoading] = useState(true); // State to manage loading status
  const [filter, setFilter] = useState('all'); // State to manage the current filter
  const [searchTitle, setSearchTitle] = useState(''); // State to manage the search input
  const [currentPage, setCurrentPage] = useState(1); // State to track the current page
  const [todosPerPage, setTodosPerPage] = useState(5); // State to set the number of todos per page
  const { token } = useAuth(); // Retrieve authentication token
  const todoService = useTodoService(); // Service to interact with the todo API

  const fetchTodos = async () => {
    try {
      const data = await todoService.getTodos(filter, searchTitle);
      if (!data?.error) {
        setTodos(data); // Update todos state with fetched data
      } else {
        setError(data.error); // Set error state if there's an error in the response
      }
    } catch (error) {
      setError('An error occurred while fetching data'); // Handle fetch errors
    } finally {
      setLoading(false); // Set loading to false after fetch attempt
    }
  };

In this snippet, we initialize the state variables needed for managing todos, errors, loading status, filters, search titles, pagination, and authentication. The fetchTodos function is defined to retrieve the list of todos from the service, handling any errors that may occur.

  useEffect(() => {
    if (token) {
      fetchTodos(); // Fetch todos when the component mounts or when dependencies change
    }
  }, [token, filter, searchTitle]);

  const handleDelete = async (id) => {
    try {
      await todoService.deleteTodo(id); // Delete the todo with the given id
      fetchTodos(); // Refresh the list of todos after deletion
    } catch (error) {
      setError('An error occurred while deleting the todo'); // Handle deletion errors
    }
  };

  // Calculate pagination values
  const indexOfLastTodo = currentPage * todosPerPage; // Determine the index of the last todo on the current page
  const indexOfFirstTodo = indexOfLastTodo - todosPerPage; // Determine the index of the first todo on the current page
  const currentTodos = todos.slice(indexOfFirstTodo, indexOfLastTodo); // Slice the todos array to get the current page's todos
  const totalPages = Math.ceil(todos.length / todosPerPage); // Calculate the total number of pages

  const handlePageChange = (event, value) => {
    setCurrentPage(value); // Update the current page when the user navigates
  };

This snippet includes the useEffect hook to fetch todos whenever the token, filter, or search title changes. It also defines the handleDelete function to remove a todo and recalculate pagination values to determine which todos to display on the current page. The handlePageChange function updates the current page when the user navigates through pages.

  return (
      ...
      <FormControl style={{ marginBottom: '20px', marginTop: '10px', minWidth: '120px' }}>
        <InputLabel>Items per page</InputLabel>
        <Select
          value={todosPerPage}
          onChange={(e) => {
            setTodosPerPage(Number(e.target.value)); // Update the number of todos per page
            setCurrentPage(1); // Reset to first page when changing items per page
          }}
          label="Items per page"
        >
          <MenuItem value={5}>5</MenuItem>
          <MenuItem value={10}>10</MenuItem>
          <MenuItem value={20}>20</MenuItem>
        </Select>
      </FormControl>
    ...

Here, we render a dropdown menu to allow users to select the number of todos displayed per page. Changing this value resets the current page to the first page to ensure a smooth user experience.

      {todos.length ? (
        <>
          <List>
            {currentTodos.map(todo => (
              <ListItem key={todo.id} sx={{ width: '400px' }}>
                <TodoDetails todo={todo} onDelete={handleDelete} /> {/* Render each todo item */}
              </ListItem>
            ))}
          </List>
          <Box display="flex" justifyContent="center" mt={2}>
            <Pagination 
              count={totalPages} 
              page={currentPage} 
              onChange={handlePageChange} 
              color="primary" 
            /> {/* Render pagination controls */}
          </Box>
        </>
      ) : (
        <Typography variant="h6">No Todos Found</Typography> {/* Display message if no todos */}
      )}

This snippet renders the list of current todos and includes the MUI Pagination component to navigate between pages. If no todos are found, a message is displayed to inform the user.

Server-Side Pagination

While client-side pagination is useful for small datasets, it can become inefficient as the number of to-do items grows significantly. This is where server-side pagination comes into play. By handling pagination on the server, you can reduce the amount of data sent to the client, improving performance and reducing load times. Server-side pagination is especially beneficial when dealing with large datasets, as it allows the server to send only the necessary data for the current page, rather than the entire dataset.

Why Server-Side Pagination Matters

  1. Performance Optimization: By fetching only the required data for the current page, you minimize the amount of data transferred over the network, leading to faster load times and a more responsive application.
  2. Scalability: Server-side pagination allows your application to handle larger datasets efficiently, as the server manages the data slicing and only sends the relevant portion to the client.
  3. Reduced Client Load: With less data to process and render, the client-side application can perform better, especially on devices with limited resources.

Example: Fetch Request for Server-Side Pagination

To implement server-side pagination, you need to modify your fetch request to include pagination parameters such as page and limit. Here's an example of how the fetch request might look:

const getTodos = async (filter = 'all', searchTitle = '', page = 1, limit = 5) => {
  let url = '/api/todos';
  let params = [];
  
  if (filter === 'completed') {
    params.push('done=true'); // Add filter for completed todos
  } else if (filter === 'pending') {
    params.push('done=false'); // Add filter for pending todos
  }
  
  if (searchTitle) {
    params.push(`title=${encodeURIComponent(searchTitle)}`); // Add search title parameter
  }
  
  // Add pagination parameters
  params.push(`page=${page}`);
  params.push(`limit=${limit}`);
  
  if (params.length > 0) {
    url += '?' + params.join('&'); // Construct the query string
  }

  const response = await fetch(url, {
    headers: {
      'Authorization': `Bearer ${token}`, // Include authorization header
    },
  });
  if (!response.ok) {
    throw new Error('Failed to fetch todos'); // Handle fetch errors
  }
  return response.json(); // Return the JSON response
};

In this example, the backend API supports page and limit query parameters and returns only the to-do items for the requested page. It's important to note that the response body does not include a total count or any other pagination metadata: it is simply a bare array containing the current page's items.

Since the server does not tell us how many pages exist, we need to work that out ourselves on the client. We can do this by issuing a second request that reuses the same done and title parameters as the main request, but leaves out page and limit entirely. Because that request is not paginated, the server returns every to-do that matches the filter and search criteria, so we can take the length of that array and divide it by the page size to obtain totalPages. Here's what that companion request might look like:

const getTodosCount = async (filter = 'all', searchTitle = '') => {
  let url = '/api/todos';
  let params = [];

  if (filter === 'completed') {
    params.push('done=true'); // Add filter for completed todos
  } else if (filter === 'pending') {
    params.push('done=false'); // Add filter for pending todos
  }

  if (searchTitle) {
    params.push(`title=${encodeURIComponent(searchTitle)}`); // Add search title parameter
  }

  // No page or limit parameters here, so the server returns every matching todo
  if (params.length > 0) {
    url += '?' + params.join('&'); // Construct the query string
  }

  const response = await fetch(url, {
    headers: {
      'Authorization': `Bearer ${token}`, // Include authorization header
    },
  });
  if (!response.ok) {
    throw new Error('Failed to fetch todos count'); // Handle fetch errors
  }
  const allMatchingTodos = await response.json(); // Parse the full list of matching todos
  return allMatchingTodos.length; // Use its length to derive the total number of pages
};

This approach ensures that your application remains efficient and scalable as the dataset grows, even though computing the total number of pages now happens on the client rather than being handed to us directly by the server.

Why It Matters

Implementing pagination is crucial for maintaining a clean and efficient user interface, especially as the number of tasks grows. Without pagination, users might find it overwhelming to scroll through a long list of items. By breaking the list into pages, you enhance the user experience, making it easier for users to find and manage their tasks. This feature not only improves usability but also optimizes the performance of your application by reducing the amount of data rendered at once.

Ready to make your to-do application more dynamic and user-friendly? Let's dive into the practice section and start implementing pagination together!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal