A simple context-based reusable approach to pagination state management for asynchronous data

Manipulating, displaying, and handling asynchronous paginated data is one of the fundamental requirements for most software projects. Depending on the complexity and design requirements the specific implementation varies however from a high level approaching the problem in a reusable manner can help save a lot of future inconsistencies or code duplication as the project develops.

I would like to demonstrate a simple overview of such an approach that is not only specific to pagination but can also be extended to track search-specific indexes, filters, queries, and so on.

Our demonstration objectives would be to:

  • Fetch data from a REST API and display the data in a responsive UI
  • Implement reusable pagination logic
  • Reuse logic for different data

To start with let us declare a basic boilerplate for our core application.
You can check out the working code sandbox where the project is implemented in TS here

https://codesandbox.io/embed/mystifying-butterfly-v49u8?fontsize=14&hidenavigation=1&theme=dark

This is our entry file. As you can see the components are arranged in a certain pattern.

import React from "react";
import "./styles.css";
import DataWrapper from "./DataWrapper";
import Pagination from "./Pagination";
import Manager from "./Manager";

export default function App() {
  return (
    <div className="App">
      <Manager>
        <DataWrapper />
        <Pagination />
      </Manager>
    </div>
  );
}

Let me elaborate on the architecture. Let’s start with the Manager which will hold our core logic for state management.

import React, { createContext, useState } from "react";

interface IPaginationState {
  page?: number;
  maxPage?: number;
}

// Declare the empty context
export const ManagerContext = createContext<IManagerContext>({});

export interface IManagerContext {
  paginationState?: IPaginationState;
  setPaginationState?: React.Dispatch<React.SetStateAction<IPaginationState>>;
}
export default function Manager(props: any) {
  const [paginationState, setPaginationState] = useState<IPaginationState>({
    page: 1,
    maxPage: 5
  });

  return (
    <ManagerContext.Provider value={{ paginationState, setPaginationState }}>
      {props.children}
    </ManagerContext.Provider>
  );
}


This simply stores the pagination state in the parent component. The paginationState and setPaginationState dispatch function are passed as values to the ManagerContext which we will use in our nested components to access/manipulate the pagination state.

For simplicity, we will not design a complex pagination state. Instead, use basic parameters page and maxPage to determine how many items to display and what is the max allowed pages.

Now let us look at the DataWrapper component.
This component will only be responsible for displaying the data it receives to the UI.

But before that let us look briefly into the useFetchData custom hook that we will use to fetch data along with config for pagination. This would ideally be your API service class that will handle the HTTP overheads and asynchronous handling.
The hook takes the base URL and a memorized object containing the current page number from the pagination state.

import { useEffect, useState } from "react";
import axios from "axios";

export enum LoadingStates {
  Loading = "Loading",
  Failed = "Failed",
  Success = "Success",
  Idle = "Idle"
}

interface IState {
  data: Array<any>;
  loadingState: LoadingStates;
}
const initialState: IState = {
  data: [],
  loadingState: LoadingStates.Idle
};

const constructListUrl = (base: string, config: { page: number }) => {
  if (config?.page) {
    base += "?_limit=";
    base += config.page;
  }
  return base;
};

export default function useFetchData(baseUrl: string, config?: any): IState {
  const [state, setState] = useState<IState>(initialState);

  useEffect(() => {
    setState({ data: [], loadingState: LoadingStates.Loading });
    axios
      .get(constructListUrl(baseUrl, config))
      .then((res) => {
        if (res.status !== 200) {
          setState({ data: [], loadingState: LoadingStates.Failed });
        }
        if (res?.data && res.status === 200) {
          // Successful. Set Data to state.
          setState({ data: res.data, loadingState: LoadingStates.Success });
        }
      })
      .catch((e) => {
        setState({ data: [], loadingState: LoadingStates.Failed });
      });
    // Dependancy on memoized config object.
  }, [config, baseUrl]);

  return { data: state.data, loadingState: state.loadingState };
}

This is the component where the hook is called to obtain the requested data.

NOTE: The memoization using useMemo() is important as we do not want our hook to run its effect multiple times unnecessarily. It should only run its effect if the paginationState changes or the base URL changes.

import { useContext, useMemo } from "react";
import { IManagerContext, ManagerContext } from "./Manager";
import "./styles.css";
import useFetchData, { LoadingStates } from "./useFetchData";

export default function DataWrapper() {
  const { paginationState }: IManagerContext = useContext(ManagerContext);

// This is important
  const memoizedConfig = useMemo(() => {
    return { page: paginationState?.page ?? 1 };
  }, [paginationState?.page]);

  const { data, loadingState } = useFetchData(
    "https://jsonplaceholder.typicode.com/posts",
    memoizedConfig  
  );

  if (loadingState === LoadingStates.Failed) {
    return <h4>Failed to load resources</h4>;
  }
  if (loadingState === LoadingStates.Loading) {
    return <h4>Loading</h4>;
  }

  return (
    <div className="container">
      {data &&
        data?.map((item, idx) => (
          <div className="card" key={item?.id}>
            {item?.title}
          </div>
        ))}
    </div>
  );
}

Finally, this is the last component which deals with the pagination UI and button clicks. Very straightforward using the context state and changing the pagination state using a callback function. This automatically triggers the request on page change and makes everything render synchronously. We even get the benefit of not triggering the API call when the same page button is clicked multiple times. Thanks to memoization!

Note however the dependencies are limited in this example. As the project evolves one should keep track of these dependencies and centralize them to avoid optimization issues.

import { useCallback, useContext } from "react";
import { IManagerContext, ManagerContext } from "./Manager";
import "./styles.css";

export default function Pagination() {
  const { paginationState, setPaginationState }: IManagerContext = useContext(
    ManagerContext
  );

  const handleClick = useCallback(
    (val) => {
      if (setPaginationState) {
        setPaginationState((state: any) => ({ ...state, page: val }));
      }
    },
    [setPaginationState]
  );

  return (
    <>
      <ul className="pagination">
        {Array.from(Array(paginationState?.page + 1).keys()).map((idx) => {
          return (
            <button key={idx} onClick={() => handleClick(idx + 1)}>
              {idx + 1}
            </button>
          );
        })}
      </ul>
    </>
  );
}

To conclude, this might seem like overkill for smaller projects. However, the benefits outweigh the initial boilerplate code as you only need to write this once. The ManagerWrapper can be extracted into a HOC thus allowing any number of lists/pages using the same structure to be implemented using a consistent and reliable strategy.

This will help save both your time as well as earn you points for implementing a generic approach that can be extended across the project if needed later. I will extend the above architecture to use search queries and filtering as well as wrap the logic in a HOC wrapper in my upcoming articles.

Thank you for reading and if you have any questions, suggestions, or improvements please do not hesitate to comment below; feedbacks and criticisms are highly appreciated.