Best practice for pagination with search

Hi,

I started a discussion here: https://github.com/apollographql/apollo-client/discussions/9136 but I now saw that you want to move the discussions to this form instead so I create a copy here.

Let’s say I have the following requirements for a web app:

  • Show a list of content
  • Add a button at the bottom of the page to load more content that should be added at the bottom of the list
  • Add a search field
    • Show the text “Searching…” while searching (meanwhile we are waiting for the HTTP response)
    • Show the old result until we have a new search result (ie. show the old result while we are waiting for the HTTP response)
    • Do not search for every keystroke; add a debounce for the search input
    • Cancel any ongoing request when sending a new request so only one request is active at the time
    • Be able to load more search results (add pagination at the bottom)
  • Be able to navigate from the page and then back again and keep the list state (pagination and search result)

The best solution I have come up with is the following:

export function PostList() {
  /** Keep the search string in a global state to keep it on page shift */
  const [searchTerm, setSearchTerm] = useSearchTerm();
  /** We do not want to seatch for every keystroke so lets debounce the seach string change for 300 ms */
  const debouncedSearchTerm = useDebounce(searchTerm, 300);
  /** Create a AbortController so we can cancle any ongoing search request later on */
  const abortSignalRef = React.useRef(new AbortController());
  /** Create a state to keep track of if the user is searching or not, we can not
   * use `notifyOnNetworkStatusChange` since that will set `data` to null while searching */
  const [isSearching, setIsSearching] = React.useState(false);
  /** Keep a ref of the initial search string; so we get the right cache when the user navigates back to the list */
  const searchTermRef = React.useRef(searchTerm);
  const { error, data, fetchMore, refetch } = useQuery(GET_POST_LIST, {
    variables: {
      limit: 10,
      offset: 0,
      search: searchTermRef.current || null,
    },
    context: {
      fetchOptions: {
        // We must pass signal as a geter. Otherwise can we only cancle the request once
        get signal() {
          return abortSignalRef.current.signal;
        },
      },
    },
    // This must be false, otherwise will `data` be null when calling `refetch`.
    notifyOnNetworkStatusChange: false,
  });

  /**
   * 1. Cancle the ongoing request if any 
   * 2. Start a new search when the debounced string changes
  */
  useUpdateEffect(() => {
    abortSignalRef.current = new AbortController();
    const search = debouncedSearchTerm === '' ? null : debouncedSearchTerm;
    refetch({
      search,
      offset: 0,
    }).then((): void => setIsSearching(false));
    return (): void => abortSignalRef.current.abort();
  }, [debouncedSearchTerm]);

  // Set `isSearching` to true when changing the search string
  useUpdateEffect(() => {
    setIsSearching(true);
  }, [searchTerm]);

  if (error) {
    return <p>{JSON.stringify(error)}</p>;
  } else if (!data) {
    return <p>Loading...</p>;
  }

  const loadMore = () => {
    fetchMore({
      variables: {
        offset: data.posts.offset + data.posts.limit,
      },
    });
  };

  return (
    <div>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      {isSearching && <p>Is Searching...</p>}
      <ul>
        {data.posts.posts.map((post) => (
          <li key={post.id}>
            <Link to={`/post/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
      <button onClick={() => loadMore()}>Load more</button>
    </div>
  );
}

It feels however that I’m not using @apollo/client as I should so now to my questions:

Abort signal

Is this the best way of allowing the user to abort a request:

const abortSignalRef = React.useRef(new AbortController());
const { error, data, fetchMore, refetch } = useQuery(GET_POST_LIST, {
  ...
  context: {
    fetchOptions: {
      get signal() {
        return abortSignalRef.current.signal;
      },
    },
  },
});
  
// later on
abortSignalRef.current.abort();

Can I expect this to work in a later (non-breaking-change) release?
How can I test this? I see that on this page: Testing React components - Apollo GraphQL Docs that you can mock the data. Would it be possible to return a Promise instead of raw data and get access to the context to be able to test this?

Also, is there a reason why useQuery and fetchMore accept a context but not refetch?

Seaching state

When setting notifyOnNetworkStatusChange to true, data will be null when calling refetch but if notifyOnNetworkStatusChange is false data will keep the value until we have a new value. Is this expected?

Keep search state when re-mount the component

Let’s say the user search for something. The user then clicks on an item to navigate to a new page. When the user navigates back I want to keep the search result.

The only solution I come up with was to get a separate state for the search string on the first render:

const searchTermRef = React.useRef(searchTerm);
const { error, data, fetchMore, refetch } = useQuery(GET_POST_LIST, {
  variables: {
    limit: 10,
    offset: 0,
    search: searchTermRef.current,
  },
}

Does it exist a better/intuitive way?

I have created a simple GraphQL server to demonstrate this here: https://dash.deno.com/playground/windy-hawk-11
I also created a simple web app here: https://stackblitz.com/edit/react-ts-psfqir?file=PostList.tsx

The back and forth navigation does not work completely when sowing the editor in stackblitz so if you want to test the navigation use this link instead: https://react-ts-psfqir.stackblitz.io

Thanks!

3 Likes

Hi @tjoskar, very nice write up on this rare topic. Do you have any update on the working of this approach to aborting requests? Did you use it in the end in a meaningful way? All the best!