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_connectversion 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.
_insertand_insertMany_upsertand_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.