How should I manage Local/Remote data state together with Apollo 3

Hi there,

I am trying to use apollo as a state management library. I would like to fetch some data from my graphql server and use the data directly in a component. After that I would like to change some kind of property values in this data from client. Maybe I can fetch more detail from server for this particular data. Imagine you have a product list. And you have two view mode: Compact and detailed. In compact mode you can change a particular product view without changing whole list view mode. So how should I manage this state? I have a solution but I don’t sure that is the perfect way.

I am using apollo client query directly in my component (by using withApollo or useApolloClient hook). I fetch the product list from server by using “network-only” fetch policy. By the way all product item has a unique id.

  useEffect(() => {
    client.query({
      query: GET_PRODUCTS,
      variables: { viewMode },
      fetchPolicy: 'network-only',
    });
  }, [viewMode]);

So Apollo writes products to cache and than I can reach this data by using useQuery hook with “cache-only” fetch policy.

  const { data: { products } = useQuery(GET_PRODUCTS, {
    variables,
    nextFetchPolicy: 'cache-only',
  });

With this way I am able to be change any product data by using cache.writeFragment with product id.

  cache.writeFragment({
    id: cache.identify(obj),
    fragment,
    data,
  });

P.S: writeFragment updates product item and product immediately change in UI.

Is this solution accaptable? This is working perfectly but I would like to be sure is this a right way.

Thanks

Hi! It seems to me like you’re doing a couple extra steps that are probably not necessary.

If I understood you correctly, you have a list of products that you get from the server, displaying a small subset of data from those products (compact view). Then, your app provides the option to switch single products from compact to detailed view on demand.

First of all, you can combine your first two code snippets to:

const { data: { products } } = useQuery(GET_PRODUCTS, { variables });

Per default, useQuery will fetch the requested data from the cache, and if the data is not there, only then will it perform a server request and write the data to the cache. So you don’t have to manage the fetch policy yourself, it should just work out of the box.

Secondly, if you wish to only change single products from compact to detailed view (i.e. fetch more data for single products), I suggest you use a different query for that - one that fetches data only for a single product rather than all of them. Apollo’s cache implementation is smart enough to automatically update your cache afterwards, so you won’t even have to call cache.writeFragment at all.

One possible implementation:

// ProductList.jsx
const ProductList = () => {
    // Fetches data for all products in compact view
    const { data, loading } = useQuery(GET_PRODUCTS);

    if (loading) {
        // Handle loading state
    }

    return (
        products.map((product) => (
            <Product
                key={product.id}
                id={product.id}
                ... // Props needed for rendering a product in compact view
            />
        ))
    );
};

// Product.jsx
const Product = (props) => {
    const [detailed, setDetailed] = useState(false);
    // Fetches data for a single product (but skip it if `detailed` isn't set to true yet)
    const { data } = useQuery(GET_PRODUCT, {
        variables: { id: props.id },
        skip: !detailed,
    });

    return (
        ...
        // Render your product in compact view using `props`, add a button
        // for changing the `detailed` state, and as soon as `data` is
        // available, you can use that for rendering the detailed view
    );
};

I’m not sure how much this matches your exact use case, but at the very least it should give you an idea of how it can be done. Hope it helps!

If I understood you correctly, you have a list of products that you get from the server, displaying a small subset of data from those products (compact view). Then, your app provides the option to switch single products from compact to detailed view on demand.

Yes you got it almost right. Actually I didn’t do this without managing cache policy manually. Also there is a toggle button for view mode. You can change view mode for single product or all product. Actually the problem is here. ​I tried your way. But it is not working as I need. There are some weird bugs.

Thank you very much for your long answer.

Hi, @mindnektar,

I have created a repository and captured a video. You can watch my use cases. This is working exactly as I need. But haven’t found any best practices for such use cases.

Here is the repository: GitHub - ozanturhan/apollo3_state_management

By the way I don’t use writeFragment anymore for single product cache update. Apollo manage this situtation as you said. I just send a request for single product with its id. But I am still reading product list from cache directly by cache only fetch policy and get products from the server with client.query whenever my parameters - like view mode- changes. Because If I use useQuery without fetch-policy the application doesn’t work properly. You can see that in getProduct.js.

export const useGetProducts = view => {
  const [loading, setLoading] = useState(false);
  const { data } = useQuery(GET_PRODUCTS, {
    variables: { view },
    fetchPolicy: 'cache-only',
  });

  useEffect(() => {
    setLoading(true);

    client
      .query({
        query: GET_PRODUCTS,
        variables: { view },
      })
      .then(() => setLoading(false));
  }, [view]);

  return {
    data,
    isAllSelected: data?.products.every(item=> item.selected),
    loading,
  };
};

I am also using local only fields for product selection.

  query GetProducts($view: String) {
    products(view: $view) {
      id
      title
      selected @client
      detail {
        price
        discount
      }
    }
  }

I should update selection status for all products or single product.

I update selected field for single product with selectProduct function:

export const selectProduct = (product, selected) => {
  cache.writeFragment({
    id: cache.identify(product),
    fragment: gql`
      fragment ProductSelectFragment on Product {
        selected
      }
    `,
    data: {
      selected,
    },
  });
};

For all product selectAllProduct:

export const selectAllProduct = (selected, view) => {
  const data = client.readQuery({ query: GET_PRODUCTS, variables: { view } });

  client.writeQuery({
    query: GET_PRODUCTS,
    data: {
      products: data.products.map(order => ({ ...order, selected })),
    },
    variables: { view },
  });
};

Hello again, I updated example. I added to repository another aproach. I think it is more useful than the my first approach and more close to @mindnektar advice. So If someone reads this thread, they can compare “client_useful” and “client_useless” folders in the repository (ozanturhan/apollo3_state_management).

I used useQuery for product list and product detail. @mindnektar as you mentioned before I don’t touch fetch policy anymore. Apollo is handling that.

But I still need to touch cache directly. if I open the page in detailed mode first, I don’t want to fetch product list from server again in compact mode. In this scenario if I don’t write product list for compact mode, apollo is fetching products again. Because Apollo caches queries with variables. Here is the example:

  const { data, loading, error } = useQuery(GET_PRODUCTS, {
    variables,
    onCompleted: result => {
      if (variables.view === 'detailed') {
        const existingCompactData = client.readQuery({
          query: GET_PRODUCTS,
          variables: { view: 'compact' },
        });

        if (!existingCompactData) {
          client.writeQuery({
            query: GET_PRODUCTS,
            variables: { view: 'compact' },
            data: result,
          });
        }
      }
    },
  });

In this way, I can see the product list in compact mode without fetching it after the detailed mode. I couldn’t find any other solution for this kind of example.

Thank you again @mindnektar.

Nice! There is one way to accomplish what you’ve done in a more generic fashion, using the keyArgs API:

const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          products: {
            keyArgs: false
          }
        }
      }
    }
  }
});

By removing view from the products field’s keyArgs, you are essentially telling the Apollo cache to disregard the view argument when differentiating between cache objects, which means that both the detailed and the compact view will reference the same cache object, and that in turn means that when the detailed view is already in the cache, the compact view will automatically read it, and if the compact view is already in the cache, the detailed view will still make a server request because there are missing fields in the cache object. This should make your onCompleted callback unnecessary.

Thank you @mindnektar that’s great idea. I overlooked kesArgs in the documentation. But it doesn’t work properly in my case. If I open page in compact mode first and change to detailed view then apollo doesn’t send request for Product query it sends ProductDetail queries for all individual items.

If I use useLazyQuery for individual products for changing it’s view mode, apollo doesn’t send request anymore. That is very suitable for my case. I send query to server whenever I click product view toggle (not list view toggle).

export const useGetProductDetail = (product, view) => {
  const [isDetailVisible, setDetailVisible] = useState(view === 'detailed');

  useEffect(() => {
    setDetailVisible(view === 'detailed');
  }, [view]);

  const [getProductDetail, { loading }] = useLazyQuery(GET_PRODUCT_DETAIL, {
    variables: { id: product.id }
  });

  return {getProductDetail, loading, isDetailVisible, setDetailVisible};
};

const {getProductDetail, loading, isDetailVisible, setDetailVisible } = useGetProductDetail(product, view);

const handleToggleView = () => {
  if (!isDetailVisible && !product.detail) {
    getProductDetail()
  }

  setDetailVisible(!isDetailVisible);
};

But apollo still doesn’t send request for products query for detailed mode because we were disable keyArgs for this query in type policies. So because of that it could not make directly my onCompleted callback unnecessary unfortunately.

But I found any other solution for that. I disabled keyArgs as you said. I added view field to server for product list query result. And I used refetch function for product list like that:

  const { data, loading, error, refetch } = useQuery(GET_PRODUCTS, {
    variables: { view },
    notifyOnNetworkStatusChange: true,
  });

  const products = data?.products;

  // Refetch for detailed view once
  useEffect(() => {
    if (view === 'detailed' && products.view !== 'detailed') {
      refetch();
    }
  }, [view, products]);

It’s look like working properly. Do you think this is an appropriate use?