Apollo-client: Optimistic update for subscriptions

Created on 2 Sep 2019  路  10Comments  路  Source: apollographql/apollo-client

I've got a subscription (a hasura server live-query) like:

subscription get_items {
        item {
          id
          name
        }
      }

When doing a mutation with optimisticResponse set, the subscription will not be updated optimistically. If I use a the same gql, but instead as a query, everything works perfectly as expected. I guess that subscription cache is currently not updated by optimisticResponse. Also, manual cache updating will not work since subscription results can't be retrieved by the cache (no cache.readSubscription).

Is there a particular reason why subscriptions don't have any optimistic capabilities? Is this a bug or more of a feature request? Sorry for not filling the whole template. I can go and create a reproducible example, but I'm not yet sure if absence of optimism in subscriptions is "by design".

Most helpful comment

So I've been running into this as well (also using Hasura). In my case, I found that subscription queries never return results from the cache. Digging into this, it appears that this is intended (it is obliquely referenced here when they say "Subscriptions are similar to queries...but instead of immediately returning a single answer, a result is sent every time a particular event happens on the server").

As pointed out by @bkniffler, as well as kinda mentioned in the ApolloClient docs, if you want to have a GQL subscription interact with the cache (and generally behave the way ApolloClient queries behave), you need to use the subscribeToMore option of watchQuery() (it's actually an option on the return object of watchQuery()). The option doesn't have great documentation, but can be seen here. A downside of this approach is that, for each subscription operation, you need to provide two GQL documents (a query document and a subscription document). For Hasura users, these documents are almost identical.

I decided to simplify this by extending ApolloClient and adding a new liveQuery() method.

class CustomApolloClient extends ApolloClient {
  liveQuery<T, V>(options: Omit<WatchQueryOptions<V>, 'pollInterval'>) {
    const { query } = options;
    const queryString = query.loc!.source.body;
    const subscription = gql(queryString.replace('query', 'subscription'));

    const watchQuery = this.watchQuery<T, V>(options);

    let subscribeToMoreTeardown: (() => void) | undefined;

    return watchQuery.valueChanges.pipe(
      tapOnSubscribe(() => {
        subscribeToMoreTeardown = watchQuery.subscribeToMore({
          document: subscription,
          updateQuery: (_, curr) => curr.subscriptionData.data,
          variables: options.variables,
        });
      }),
      finalize(() => {
        subscribeToMoreTeardown!();        
      }),
      share(),
    );
  }
}

/** Triggers callback every time a new observer subscribes to this chain. */
function tapOnSubscribe<T>(callback: () => void): MonoTypeOperatorFunction<T> {
  return (source: Observable<T>): Observable<T> =>
    defer(() => {
      callback();
      return source;
    });
}

This method receives a single query operation document and generates a subscription operation document to pair with it. It than constructs a watchQuery observable which handles subscribeToMore-ing results from the database. The result of this is that you can use liveQuery() to subscribe to data from Hasura and it utilizes the cache in the same manner as a watchQuery().

Hope this helps.

All 10 comments

Wondering the same, also using Hasura. Follow.

For hasura, there seems to exist is a workaround, as suggested by @heyrict here: https://github.com/hasura/graphql-engine/issues/2317#issuecomment-527330779

Basically you'll have a query instead of a subscription and you'll use the queries subscribeToMore capability to your subscription. The query can then be updated by optimisticResponse and will also be updated by the subscription.

I still think that subscriptions should have some sort of cache API like queries do and they should be automatically updated by optimisticResponse.

So I've been running into this as well (also using Hasura). In my case, I found that subscription queries never return results from the cache. Digging into this, it appears that this is intended (it is obliquely referenced here when they say "Subscriptions are similar to queries...but instead of immediately returning a single answer, a result is sent every time a particular event happens on the server").

As pointed out by @bkniffler, as well as kinda mentioned in the ApolloClient docs, if you want to have a GQL subscription interact with the cache (and generally behave the way ApolloClient queries behave), you need to use the subscribeToMore option of watchQuery() (it's actually an option on the return object of watchQuery()). The option doesn't have great documentation, but can be seen here. A downside of this approach is that, for each subscription operation, you need to provide two GQL documents (a query document and a subscription document). For Hasura users, these documents are almost identical.

I decided to simplify this by extending ApolloClient and adding a new liveQuery() method.

class CustomApolloClient extends ApolloClient {
  liveQuery<T, V>(options: Omit<WatchQueryOptions<V>, 'pollInterval'>) {
    const { query } = options;
    const queryString = query.loc!.source.body;
    const subscription = gql(queryString.replace('query', 'subscription'));

    const watchQuery = this.watchQuery<T, V>(options);

    let subscribeToMoreTeardown: (() => void) | undefined;

    return watchQuery.valueChanges.pipe(
      tapOnSubscribe(() => {
        subscribeToMoreTeardown = watchQuery.subscribeToMore({
          document: subscription,
          updateQuery: (_, curr) => curr.subscriptionData.data,
          variables: options.variables,
        });
      }),
      finalize(() => {
        subscribeToMoreTeardown!();        
      }),
      share(),
    );
  }
}

/** Triggers callback every time a new observer subscribes to this chain. */
function tapOnSubscribe<T>(callback: () => void): MonoTypeOperatorFunction<T> {
  return (source: Observable<T>): Observable<T> =>
    defer(() => {
      callback();
      return source;
    });
}

This method receives a single query operation document and generates a subscription operation document to pair with it. It than constructs a watchQuery observable which handles subscribeToMore-ing results from the database. The result of this is that you can use liveQuery() to subscribe to data from Hasura and it utilizes the cache in the same manner as a watchQuery().

Hope this helps.

@thefliik hey this is really great. I'm curious how this has worked out for you so far in production, are you happy with this solution? I'm planning on doing something similar.

Greetings Apollo team.

Any plans to make subscriptions updatable from the cache after mutations?

I may have found a simpler work-around but haven't dug deep enough to determine if this is fortunate unintended behaviour that may be removed at some point. If someone with a deeper understanding of Hasura and Apollo understands why this works, please let us know.

I have a functional React component that uses both useQuery and useSubscription and passes both the same document (minus the subscription/query string diff). Each document has the same Operation Name. All I do then is use optimisticResponse in my mutation and the local agent who made the mutation updates instantly and other clients update after about 1 subscription latency, like 1 second I think. The React UI is using the query result and ignores the subscription result.

I tried to simplify and clean up a code example below but realized post is a poor example. We're actually fetching a single database row which contains grouping data for a list (job board). I hope you get the idea though.

```jsx const postQueryString =
query FetchJobBoardPost{
jobBoardPosts: post_by_pk(id: "1234"){
id title
}
}
; const POST_QUERY = gql${groupingQueryString}; const POST_SUBSCRIPTION = gql${gpostQueryString.replace('query', 'subscription')}`;

const JobBoard = (props) => {
const [updatePostMutate] = useMutation(UPDATE_POST_MUTATION);
const postQueryResult = useQuery(POST_QUERY);
const postSubscriptionResult = useSubscription(POST_SUBSCRIPTION);

const handleButtonClick = () => {
const optimisticResponse = {
__typename: 'Mutation',
updatePost: {
id: '123',
__typename: 'post',
title: newTitle,
},
};

updatePostMutate({
    variables: { newTitle },
    optimisticResponse,
  });

}

// React UI here using postQueryResult
}

@nolandg
This does not work as postSubscriptionResult never gets called.

@jdgamble555 Not sure what you mean. postSubscriptionResult is not a function that can be called. It's the result object returned by the Apollo Client query hook. It has props like loading, data, error etc. By calling the hook, you run the query (unless you specify skip = true). I don't think the fact that it's not referenced later affects the whether the query is run. In another heavily optimized compiled language it might be removed but JS doesn't appear to do that.

@nolandg - I apologize for my ignorance, as I am an angular, typescript, and svelte developer, not react...

So I am assuming the fact that it is not reference indicates the same thing as if subscribe() is called in svelte, typescript, or angular... will try it that way...

Since it is not referenced, I imagine you could just run useSubscription(POST_SUBSCRIPTION); on its own without setting it as a variable to confuse things.

@jdgamble555 Yes, you could just call useSubscription(POST_SUBSCRIPTION); without assigning the result but it's a pain for debugging, hard to monitor it with console, etc. I liked to compare the results of each in the console.

I suppose not assigning it though would make the fact that there are intended side effects more obvious.

Was this page helpful?
0 / 5 - 0 ratings