Get real-time updates from SQL Connect

Your client code can subscribe to queries to get real-time updates when the result of the query changes.

Before you begin

  • Set up SDK generation for your project as described in the documentation for web, Apple platforms, and Flutter.

    • You must enable client-side caching for all of your generated SDKs. Specifically, every SDK configuration must contain a declaration like the following:
    clientCache:
      maxAge: 5s
      storage: ... # Optional.
    
  • Your app clients must be must be using a recent version of the SQL Connect core SDK:

    • Apple: Firebase SQL Connect SDK for Swift version 11.12.0 or newer
    • Web: JavaScript SDK version 12.12.0 or newer
    • Flutter: firebase_data_connect version 0.3.0 or newer
  • Regenerate your client SDKs using version 15.14.0 of the Firebase CLI, or newer.

Subscribing to query results

You can subscribe to a query to respond to changes in the query result. For example, suppose you have the following schema and operations defined in your project:

# dataconnect/schema/schema.gql

type Movie @table(key: "id") {
  id: UUID! @default(expr: "uuidV4()")
  title: String!
  releaseYear: Int
  genre: String
  description: String
  averageRating: Int
}
# dataconnect/connector/operations.gql

query GetMovieById($id: UUID!) @auth(level: PUBLIC) {
  movie(id: $id) {
    id
    title
    releaseYear
    genre
    description
  }
}

mutation UpdateMovie(
  $id: UUID!,
  $genre: String!,
  $description: String!
) {
  movie_update(id: $id,
    data: {
      genre: $genre
      description: $description
    })
}

To subscribe to changes in the result of running GetMovieById:

Web

import { subscribe, DataConnectError, QueryResult } from 'firebase/data-connect';
import { getMovieByIdRef, GetMovieByIdData, GetMovieByIdVariables } from '@dataconnect/generated';

const queryRef = getMovieByIdRef({ id: "<MOVIE_ID>" });

// Called when receiving an update.
const onNext = (result: QueryResult<GetMovieByIdData, GetMovieByIdVariables>) => {
  console.log("Movie <MOVIE_ID> updated", result);
}

const onError = (err?: DataConnectError) => {
  console.error("received error", err);
}

// Called when unsubscribing or when the subscription is automatically released.
const onComplete = () => {
  console.log("subscription complete!");
}

const unsubscribe = subscribe(queryRef, onNext, onError, onComplete);

Web (React)

import { subscribe, QueryResult } from 'firebase/data-connect';
import { getMovieByIdRef, GetMovieByIdData, GetMovieByIdVariables } from '@dataconnect/generated';
import { useState, useEffect } from "react";

export const MovieInfo = ({ id: movieId }: { id: string }) => {
  const [movieInfo, setMovieInfo] = useState<GetMovieByIdData>();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const queryRef = getMovieByIdRef({ id: movieId });

    function updateUi(result: QueryResult<GetMovieByIdData, GetMovieByIdVariables>): void {
      setMovieInfo(result.data);
      setLoading(false);
    }

    const unsubscribe = subscribe(
      queryRef,
      updateUi,
      (err) => {
        setError(err ?? new Error("Unknown error occurred"));
        setLoading(false);
      }
    );

    return () => unsubscribe();
  }, [movieId]);

  if (loading)
    return <div>Loading movie details...</div>;
  if (error || !movieInfo || !movieInfo.movie)
    return <div>Error loading movie details: {error?.message}</div>;

  return (
    <div>
      <h2>{movieInfo.movie.title} ({movieInfo.movie.releaseYear})</h2>
      <ul>
        <li>Genre: {movieInfo.movie.genre}</li>
        <li>Description: {movieInfo.movie.description}</li>
      </ul>
    </div>
  );
};

SQL Connect also supports caching and real-time subscriptions using TanStack. When you specify react: true or angular: true in your connector.yaml file, SQL Connect generates bindings for React or Angular using TanStack.

These bindings can work alongside of SQL Connect's built-in real-time support, but only with some difficulty. We recommend that you use either the TanStack-based bindings or SQL Connect's built-in real time support, but not both.

Note that SQL Connect's own real-time implementation has some advantages over the TanStack bindings:

  • Normalized caching: SQL Connect implements normalized caching, which improves data consistency as well as memory and network efficiency compared to query-level caching. With normalized caching, if an entity updates in one area of your app, it will also update in other areas that use that entity.
  • Remote invalidation: SQL Connect can remotely invalidate cached   entities on all subscribed devices.

If you choose not to use TanStack, you should remove the react: true and angular: true settings from your connector.yaml file.

iOS

struct ListMovieView: View {
    // QueryRef has the Observable attribute, so its properties will
    // automatically trigger updates on changes.
    private var queryRef = connector.listMoviesByGenreQuery.ref(genre: "Sci-Fi")

    // Store the handle to unsubscribe from query updates.
    @State private var querySub: AnyCancellable?

    var body: some View {
        VStack {
            // Use the query results in a View.
            ForEach(queryRef.data?.movies ?? [], id: \.self.id) { movie in
                    Text(movie.title)
                }
        }
        .onAppear {
            // Subscribe to the query for updates using the Observable macro.
            Task {
                do {
                    querySub = try await queryRef.subscribe().sink { _ in }
                } catch {
                    print("Error subscribing to query: \(error)")
                }
            }
        }
        .onDisappear {
          querySub?.cancel()
        }
    }
}

Flutter

Import your project's generated SDK:

import 'package:flutter_app/dataconnect_generated/generated.dart';

Then call the subscribe() method on a query reference:

final queryRef = MovieConnector.instance.getMovieById(id: "<MOVIE_ID>").ref();
final subscription = queryRef.subscribe().listen((result) {
  final movie = result.data.movie;
  if (movie != null) {
    // Execute your logic to update the UI with the refreshed movie information.
    updateUi(movie.title);
  }
});

To stop updates, you can call subscription.cancel().

Once you are subscribed to the query as in the preceding example, you will get updates whenever the result of the specific query changes. For example, if another client executes the UpdateMovie mutation on the same ID you subscribed to, you will get an update.

Implicit query refresh signals

In the preceding example, you were able to subscribe to a query and get real-time updates without any additional modifications to your operations. In particular, you didn't need to specify that the UpdateMovie mutation can affect the result of the GetMovieById query.

This is possible because the GetMovieById query implicitly gets a refresh signal from the UpdateMovie mutation. Implicit refresh signals are sent between a subset of the queries and mutations that you might write:

If your query performs a single entity lookup by primary key, then any mutation that writes to the same entity, also identified by its primary key will implicitly trigger a refresh signal.

  • _insert and _insertMany
  • _upsert and _upsertMany
  • _update
  • _delete

_deleteMany and _updateMany don't send refresh signals.

In the prior example, the GetMovieById query looks up a single movie by ID (movie(id: $id)) and the UpdateMovie mutation updates a single movie, specified by ID (movie_update(id: $id, ...)), so the query can take advantage of implicit refresh.

Insert and upsert operations can trigger implicit refresh signals when you are keying off a known value, such as a Firebase Authentication user's UID.

For example, consider a query like the following:

query GetExtendedProfileByUser @auth(level: USER) {
  profile(key: { id_expr: "auth.uid" }) {
    id
    status
    photoUrl
    socialLink
  }
}

The query would implicitly receive a refresh signal from a mutation like the following:

mutation UpsertExtendedProfile($status: String, $photoUrl: String, $socialLink: String) @auth(level: USER) {
  profile_upsert(
    data: {
      id_expr: "auth.uid"
      status: $status
      photoUrl: $photoUrl
      socialLink: $socialLink
    }
  ) {
    id
    status
    photoUrl
    socialLink
  }
}

When your queries or mutations are more complicated, you will need to specify the conditions that require a query refresh. Continue to the next section to learn how.

Explicit query refresh signals

In addition to the refresh signals that are implicitly sent by mutations to queries, you can also explicitly specify when a query should receive a refresh signal. You do this by annotating your queries with the @refresh directive.

Using the @refresh directive is required whenever your queries don't meet the specific criteria (see above) for automatic automatic refresh. Some examples of queries that must include this directive include:

  • Queries that retrieve lists of entities
  • Queries that perform joins on other tables
  • Aggregation queries
  • Queries using native SQL
  • Queries using custom resolvers

You can specify a refresh policy in two ways:

Time-based intervals

Refresh the query on a fixed time interval.

For example, suppose your very active user base can result in a movie's cumulative rating being updated many times every minute, particularly after a movie's release. Rather than refreshing the query every time the rating changes, you could instead refresh the query every few seconds, to get updates reflecting the cumulative result of potentially several mutations.

# dataconnect/connector/operations.gql

query GetMovieRating($id: UUID!) @auth(level: PUBLIC) @refresh(every: {seconds: 30}) {
  movie(id: $id) {
    id
    averageRating
  }
}

Mutation execution

Refresh the query when a specific mutation is executed. This approach makes explicit which mutations have the potential to change the result of the query.

For example, suppose you have a query that retrieves information about multiple movies instead of a specific one. This query should refresh whenever a mutation has updated any of the movie records.

query ListMovies($offset: Int)
    @auth(level: PUBLIC, insecureReason: "Anyone can list all movies.")
    @refresh(onMutationExecuted: { operation: "UpdateMovie" }) {
  movies(limit: 10, offset: $offset) {
    id
    title
    releaseYear
    genre
    description
  }
}

You can also specify a CEL expression condition that must be satisfied for the mutation to trigger a query refresh.

Doing so is highly recommended. The more precise you can be when specifying the condition, the fewer unnecessary database resources will be consumed, and the more responsive your app will be.

For example, suppose you had a query that listed movies only in a specified genre. This query should only refresh when a mutation updates a movie in the same genre:

query ListMoviesByGenre($genre: String, $offset: Int)
    @auth(level: PUBLIC, insecureReason: "Anyone can list movies.")
    @refresh(onMutationExecuted: {
      operation: "UpdateMovie",
      condition: "request.variables.genre == mutation.variables.genre"
    }) {
  movies(
      where: { genre: { eq: $genre } },
      limit: 10,
      offset: $offset) {
    id
    title
    releaseYear
    genre
    description
  }
}

CEL bindings in @refresh conditions

The condition expression in onMutationExecuted has access to two contexts:

request

The state of the query being subscribed to.

Binding Description
request.variables Variables passed to the query (for example, request.variables.id)
request.auth.uid Firebase Authentication UID of the user who executed the query
request.auth.token Dictionary of Firebase Authentication token claims for the user who executed the query
mutation

The state of the mutation that executed.

Binding Description
mutation.variables Variables passed to the mutation (e.g., mutation.variables.movieId)
mutation.auth.uid Firebase Authentication UID of the user who executed the mutation
mutation.auth.token Dictionary of Firebase Authentication token claims for the user who executed the mutation
Common Patterns
# Refresh only when the mutation targets the same entity
"request.variables.id == mutation.variables.id"

# Refresh only when the same user who subscribed makes a change
"request.auth.uid == mutation.auth.uid"

# Refresh when a specific field value matches a condition
"request.auth.uid == mutation.auth.uid && mutation.variables.status == 'PUBLISHED'"

# Refresh when a specific flag is set in the mutation
"mutation.variables.isPublic == true"

Multiple @refresh directives

You can specify the @refresh directive multiple times on a query to trigger a refresh whenever any of the criteria specified by one of the @refresh directives are satisfied.

For example, the following query will refresh every 30 seconds as well as whenever one of the specified mutations are executed:

query ListMovies($offset: Int)
    @auth(level: PUBLIC, insecureReason: "Anyone can list all movies.")
    @refresh(every: {seconds: 30})
    @refresh(onMutationExecuted: { operation: "UpdateMovie" })
    @refresh(onMutationExecuted: { operation: "BulkUpdateMovies" }) {
  movies(limit: 10, offset: $offset) {
    id
    title
    releaseYear
    genre
    description
  }
}

Reference

See the @refresh directive reference for more examples.