DEV Community

Cover image for React Apollo: Understanding Fetch Policy with useQuery
SeongKuk Han
SeongKuk Han

Posted on • Updated on

React Apollo: Understanding Fetch Policy with useQuery

I've been working on an Apollo client project these days. I wasn't used to GraphQL so I struggled to understand it at the beginning.

In the app I'm working on, there was something wrong with the data consistency at some point.

I googled for that and I got to know that apollo client uses cache-first as a default fetch policy. I changed the fetch policy in the project to no-cache because I thought that would fit more to the project. After that I found some bugs with no-cache and I felt that something went wrong. I thought it might be good to learn more about fetch policy.

I'm going to talk about

  • Fetch Policy with useQuery
  • Changing Default Fetch Policy

I prepared a simple todo graphql server using nest. There is no database. The server utilizes just an array as a storage and I'm going to use this server for the following tests.

You can check the backend server code from this repository.

I set up "@apollo/client": "3.5.8" in the client.

Fetch Policy with useQuery

There are six fetch policies that are available on useQuery.

NAME DESCRIPTION
cache-first Apollo Client first executes the query against the cache. If all requested data is present in the cache, that data is returned. Otherwise, Apollo Client executes the query against your GraphQL server and returns that data after caching it. Prioritizes minimizing the number of network requests sent by your application. This is the default fetch policy.
cache-only Apollo Client executes the query only against the cache. It never queries your server in this case. A cache-only query throws an error if the cache does not contain data for all requested fields.
cache-and-network Apollo Client executes the full query against both the cache and your GraphQL server. The query automatically updates if the result of the server-side query modifies cached fields. Provides a fast response while also helping to keep cached data consistent with server data.
network-only Apollo Client executes the full query against your GraphQL server, without first checking the cache. The query's result is stored in the cache. Prioritizes consistency with server data, but can't provide a near-instantaneous response when cached data is available.
no-cache Similar to network-only, except the query's result is not stored in the cache.
standby Uses the same logic as cache-first, except this query does not automatically update when underlying field values change. You can still manually update this query with refetch and updateQueries.

Source: Apollo Documentation

I'll show you how each fetch policy works.

cache-first

This is a default fetch policy which uses cache if there is data in the cache, otherwise it fetches data from the server.

I wrote a code for this test. There are two buttons. One is used for creating a todo item and another one is used for showing or hiding a data table (mount and unmount). The data table gets data with useQuery.

Here is the code.

import { useCallback, useState } from "react";
import {
  ApolloClient,
  InMemoryCache,
  ApolloProvider,
  useQuery,
  useMutation,
  gql,
} from "@apollo/client";

let suffixIndex = 1;

const GET_TODOS = gql`
  query {
    getTodos {
      id
      content
      checked
    }
  }
`;

const CREATE_TODO = gql`
  mutation CreateTodo($content: String!) {
    ct1: createTodo(content: $content) {
      id
      content
      checked
    }
  }
`;

const client = new ApolloClient({
  uri: "http://localhost:3000/graphql",
  cache: new InMemoryCache(),
});

function TodosTable() {
  const { data: todosData, loading: todosLoading } = useQuery(GET_TODOS);

  if (todosLoading) return <span>Loading...</span>;

  return (
    <table>
      <thead>
        <tr>
          <th>id</th>
          <th>content</th>
          <th>checked</th>
        </tr>
      </thead>
      <tbody>
        {todosData?.getTodos.map((todo) => (
          <tr key={todo.id}>
            <td>{todo.id}</td>
            <td>{todo.content}</td>
            <td>{todo.checked}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function App() {
  const [createTodo] = useMutation(CREATE_TODO);
  const [todosTableVisible, setTodosTableVisible] = useState(false);

  const handleCreateButtonClick = useCallback(() => {
    createTodo({
      variables: {
        content: `Item ${suffixIndex + 1}`,
      },
    });
  }, [createTodo]);

  const toggleTodosTableVisible = useCallback(() => {
    setTodosTableVisible((prevState) => !prevState);
  }, []);

  return (
    <div>
      <button type="button" onClick={handleCreateButtonClick}>
        Create Todo Item
      </button>
      <button type="button" onClick={toggleTodosTableVisible}>
        Toggle TodosTable Visible
      </button>
      {todosTableVisible && <TodosTable />}
    </div>
  );
}

const Provider = () => (
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>
);

export default Provider;
Enter fullscreen mode Exit fullscreen mode

Let's see how it works step by step.

1. Press the toggle button

There is an empty table

2. Press the create button twice

Request headers

A request payload

The request response

You can see the created data in a network tab.

3. Press the toggle button twice (For remounting the component)

A still empty table

A same network

There is still the empty table, right? There are even no additional requests in the network tab.

4. Reload the tab and toggle the table

Image description

Now, you can see the table. Let me explain it.

At the first request, the client got an empty array from the server and it stored the data in the cache.

I remounted the table (step 3) and it found the empty array in the cache which is why the table was still empty.

After reloading, they display the data from the server because the cache is gone.

cache-only

It only uses cache. If there is no cached data, it throws an error.

I rewrote the code for testing this option.

function TodosTable() {
  const {
    data: todosData,
    loading: todosLoading,
    error,
  } = useQuery(GET_TODOS, {
    fetchPolicy: "cache-only",
  });

  if (todosLoading) return <span>Loading...</span>;

  console.log({ todosData, todosLoading, error });
  if (error) {
    return <h1>Error: {error}</h1>;
  }

  return (
    <table>
      <thead>
        <tr>
          <th>id</th>
          <th>content</th>
          <th>checked</th>
        </tr>
      </thead>
      <tbody>
        {todosData?.getTodos.map((todo) => (
          <tr key={todo.id}>
            <td>{todo.id}</td>
            <td>{todo.content}</td>
            <td>{todo.checked}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function App() {
  const [fetchTodos] = useLazyQuery(GET_TODOS);
  const [createTodo] = useMutation(CREATE_TODO);
  const [todosTableVisible, setTodosTableVisible] = useState(false);

  const handleFetchTodos = useCallback(() => {
    fetchTodos();
  }, [fetchTodos]);

  const handleCreateButtonClick = useCallback(() => {
    createTodo({
      variables: {
        content: `Item ${suffixIndex + 1}`,
      },
    });
  }, [createTodo]);

  const toggleTodosTableVisible = useCallback(() => {
    setTodosTableVisible((prevState) => !prevState);
  }, []);

  return (
    <div>
      <button type="button" onClick={handleFetchTodos}>
        Fetch Todos
      </button>
      <button type="button" onClick={handleCreateButtonClick}>
        Create Todo Item
      </button>
      <button type="button" onClick={toggleTodosTableVisible}>
        Toggle TodosTable Visible
      </button>
      {todosTableVisible && <TodosTable />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

1. Press the toggle button

An empty table

To be honest, I didn't expect this result. I thought it would throw an error since they say A cache-only query throws an error if the cache does not contain data for all requested fields. in the documentation. Anyways, let's continue.

2. Reload and press the fetch button.

A result of fetching data

You can see the response data in the network tab.

3. Press the toggle button.

Data Table

Now, you can see the data.

4. Press the create button then remount(press the toggle button twice) the table

Same Data

It is still the same. cache-only uses only cached data as you have seen.

If you fetch the data manually, it will show up as well but what if you fetch a part of the data? How will it show up?

Let's see how it appears.

const GET_TODOS2 = gql`
  query {
    getTodos {
      id
      checked
    }
  }
`;

const [fetchTodos] = useLazyQuery(GET_TODOS2);
Enter fullscreen mode Exit fullscreen mode

Partially updated

The data appears depending on which data is in the cache.


Sorry, I didn't notice that there were empty columns and all of the numbers were 2. I changed a part of the code from

<td>{todo.checked}</td>

...

const handleCreateButtonClick = useCallback(() => {
    createTodo({
      variables: {
        content: `Item ${suffixIndex + 1}`,
      },
    });
  }, [createTodo]);
Enter fullscreen mode Exit fullscreen mode

To

<td>{todo.checked ? "checked" : "unchecked"}</td>

...

const handleCreateButtonClick = useCallback(() => {
    createTodo({
      variables: {
        content: `Item ${suffixIndex}`,
      },
    });
    suffixIndex++;
  }, [createTodo]);
Enter fullscreen mode Exit fullscreen mode

cache-and-network

With this policy, it first uses a data from the cache and makes a request. The request automatically updates the data.

For this test, I removed a code that renders a loading text in TodosTable.

function TodosTable() {
  const {
    data: todosData,
    error,
  } = useQuery(GET_TODOS, {
    fetchPolicy: "cache-and-network",
  });

  if (error) {
    return <h1>Error: {error}</h1>;
  }

  return (
    <table>
      <thead>
        <tr>
          <th>id</th>
          <th>content</th>
          <th>checked</th>
        </tr>
      </thead>
      <tbody>
        {todosData?.getTodos.map((todo) => (
          <tr key={todo.id}>
            <td>{todo.id}</td>
            <td>{todo.content}</td>
            <td>{todo.checked ? "checked" : "unchecked"}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode

While loading, the component would use a data from a cache.

Since we're living in the future with our internet speed, we won't be able to recognize. So let's slow down the internet to 3G first and then start the test.

1. Create two items and press the toggle button

created two items

2. Create two items and remount the table

updates automatically

It displays data out of the box from the cache, then it updates automatically when the fetching is done.

network-only

This uses the data comming from the server and then it updates the cache.

1. Press the toggle button repeatedly

delay of network only

It has a delay until a request comes back.

For the next test, whether network-only updates the cache or not, I changed my code as below.

function TodosTable() {
  const { data: todosData, error } = useQuery(GET_TODOS, {
    fetchPolicy: "cache-only",
  });

  if (error) {
    return <h1>Error: {error}</h1>;
  }

  return (
    <table>
      <thead>
        <tr>
          <th>id</th>
          <th>content</th>
          <th>checked</th>
        </tr>
      </thead>
      <tbody>
        {todosData?.getTodos.map((todo) => (
          <tr key={todo.id}>
            <td>{todo.id}</td>
            <td>{todo.content}</td>
            <td>{todo.checked ? "checked" : "unchecked"}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function App() {
  const [fetchTodos] = useLazyQuery(GET_TODOS, {
    fetchPolicy: "network-only",
  });
  const [createTodo] = useMutation(CREATE_TODO);
  const [todosTableVisible, setTodosTableVisible] = useState(false);

  const handleFetchTodos = useCallback(() => {
    fetchTodos();
  }, [fetchTodos]);

  const handleCreateButtonClick = useCallback(() => {
    createTodo({
      variables: {
        content: `Item ${suffixIndex}`,
      },
    });
    suffixIndex++;
  }, [createTodo]);

  const toggleTodosTableVisible = useCallback(() => {
    setTodosTableVisible((prevState) => !prevState);
  }, []);

  return (
    <div>
      <button type="button" onClick={handleFetchTodos}>
        Fetch Todos
      </button>
      <button type="button" onClick={handleCreateButtonClick}>
        Create Todo Item
      </button>
      <button type="button" onClick={toggleTodosTableVisible}>
        Toggle TodosTable Visible
      </button>
      {todosTableVisible && <TodosTable />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

1. Press the fetch button then press the toggle button

it display data well

The table displays the data with cache-only. It means that network-only updated the cache.

no-cache

It's similar to network-only but it doesn't update the cache. In the code above, I changed a line which is an option of the lazy query.

 const [fetchTodos] = useLazyQuery(GET_TODOS, {
    fetchPolicy: "no-cache",
  });
Enter fullscreen mode Exit fullscreen mode
  1. Press the fetch button then press the toggle button

Empty Data

Nothing shows up in the table with cache-only because no-cache doesn't update the cache.

Changing Default Fetch Policy

As I already mentioned, a default option of useQuery and useLazyQuery is cache-first. If you want to change a default fetch policy, use defaultOptions.

const client = new ApolloClient({
  uri: "http://localhost:3000/graphql",
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: "cache-only",
      errorPolicy: "ignore",
    },
    query: {
      fetchPolicy: "network-only",
      errorPolicy: "all",
    },
    mutate: {
      errorPolicy: "all",
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

There were a lot of things that I had to know more about Apollo Client. I didn't understand why they used cache as a default. That's why I set a default fetch policy of my project to no-cache. However, I got some problems while using no-cache. One of them is that useQuery doesn't use the defaultOptions. Although the problem was solved in a commit, it seems that there were some more issues related to no-cache. I thought it will be okay to use a specific policy when it is needed but apollo cache system does something more than I expected (like automatically updating and making a rendering, refetchQueries). I think cache may be the key to using apollo client but I'll have to learn more about it. I hope this post will help you at some point. Thank you for reading the post.

Top comments (0)