React hooks pagination with useEffect - how to revert page number if request fails?

Viewed 3434

I'm testing out simple paging with react hooks, and wondering how to deal with failures to keep it valid in state?

Pseudo code:

function reducer(state, action) {
  switch (action.type) {
    case 'next':
      return {page: state.page + 1};
    case 'prev':
      return {page: state.page - 1};
    default:
      throw new Error();
  }
}

function Page() {
  const [state, dispatch] = useReducer(reducer, { page: 1});

  useEffect(() => {
    loadMore(state.page)
    .then(r => console.log(r))
    .catch(e => {
      // how to revert page without triggering this effect?
      // cause if I do dispatch({type: 'prev'}) page will change, and this will re-run
      console.log(e)
    })
  }, state.page)

  return (
    <>
      Page: {state.page}
      <button onClick={() => dispatch({type: 'prev'})}>-</button>
      <button onClick={() => dispatch({type: 'next'})}>+</button>
    </>
  );
}

Let's take this scenario:

  • we start with page 1
  • user clicks next
  • page is increased in state
  • useEffect fires, network request fails.
  • page in state is 2, but actual data is of page 1

I cannot dispatch page change event on catch, because it would trigger refetch, which I don't want to do

Another option about which I was thinking was that I should maybe increment page only after I've got the data for it, but then I would create infinite loop in useEffect cause page would change after fetching data?

4 Answers

You can also simply remove useEffect and use such function instead:

let fetch = (page, isNext)=>{

 loadMore(page)
    .then(r => {
      // ..
      isNext ? dispatch({type: 'next'}):dispatch({type: 'prev'});
    })
    .catch(e => {
      // Don't dispatch anything, you will remain on same page
    })

}

Now when user clicks next, just call that fetch(state.page + 1, true). If she clicks prev btn, use fetch(state.page - 1, false);

You could add another piece of state representing the page we'd like to see, assuming it loads.

function reducer(state, action) {
  switch (action.type) {
    case 'failed':
      return { ...state, requestedPage: null };
    case 'loaded':
      return { page: action.page, requestedPage: null };
    case 'next':
      return { ...state, requestedPage: state.page + 1 };
    case 'prev':
      return { ...state, requestedPage: state.page - 1 };
    default:
      throw new Error();
  }
}

const Page = () => {
  const [{ page, requestedPage }, dispatch] = useReducer(reducer, {
    page: 1,
    requestedPage: null,
  });

  useEffect(() => {
    loadMore(requestedPage)
      .then((r) => {
        dispatch({ type: 'loaded', page: requestedPage });
        console.log(r);
      })
      .catch((e) => {
        dispatch({ type: 'failed' });
        console.error(e);
      });
    // Make sure the dependencies are an array
  }, [requestedPage]);

  // Optimistically render the requestedPage (with a spinner?) if present,
  // otherwise current page.
  return (
    <>
      Page: {requestedPage || page}
      <button onClick={() => dispatch({ type: 'prev' })}>-</button>
      <button onClick={() => dispatch({ type: 'next' })}>+</button>
    </>
  );
};

I would use useRef() to preserve the previous page on success. If the current call fails, go back to the previous page by setting it. When entering useEffect(), check if the current page, and the page stored in the ref are the same, and do nothing if they are.

function reducer(state, { type, page }) {
  switch (type) {
    case 'set':
      return { page };
    case 'next':
      return { page: state.page + 1 };
    case 'prev':
      return { page: state.page - 1 };
    default:
      throw new Error();
  }
}

function Page() {
  const [state, dispatch] = useReducer(reducer, { page: 1 });

  const currentPage = useRef();

  useEffect(() => {
    if (currentPage.current === state.page) return;

    loadMore(state.page)
    .then(r => {
      currentPage.current = state.page;
    })
    .catch(e => {
      dispatch({ type: 'set', page: currentPage.current || 1 }); // if currentPage.current doesn't exist use page 1 as default

      console.log(e)
    })
  }, state.page)

  return (
    <>
      Page: {state.page}
      <button onClick={() => dispatch({type: 'prev'})}>-</button>
      <button onClick={() => dispatch({type: 'next'})}>+</button>
    </>
  );
}

It's usually better to avoid letting useEffect handle pagination so you can have full control of what's going on.

You could

  const [currentPage, setCurrentPage] = useState(0);
  const [lastPage, setLastPage] = useState(true);

  const fetchData = useCallback((page: number) => {
    yourFetchFunction(page).then((response) => {
      // By setting page here, you will update the page just if it works
      setCurrentPage(page);

      // If your endpoint returns last, you can also update it here
      setLastPage(response.last);

      // ... Do whatever you need with the response
    });
  }, []);

  // First run, that's why we have the useCallback
  useEffect(() => fetchData(0), [fetchData]);

  // To load the next page, we can simply fetch data passing the next page
  // State is going to be update if it succeeds. If it fails, "page is going to be reverted" (actually, not updated)
  const loadMore = () => {
    if (!lastPage) {
      fetchData(currentPage + 1);
    }
  };

Related