Realtime Queries
The example-app API supports realtime queries that instantly update your frontend to reflect backend changes. In frontend code, you can mark a query as realtime by passing live: true
. Realtime queries will watch for any backend record creates, updates, or deletes, and then fetch new data and trigger a re-render when they happen.
Realtime query support is included out-of-the-box in your @gadget-client
package, as well as in the @gadgetinc/react
hooks package, with no changes necessary to your backend implementation. Often termed as subscriptions or change events in different systems, live: true
queries offer an efficient alternative to change polling or page refreshes. Gadget's realtime queries leverage a WebSocket-based, high-performance implementation of the GraphQL @live
directive.
Using live: true
To start re-rendering your frontend components when backend data changes, add the live: true
option to your API call or React hook. For example, we can pass live: true
to useFindMany
or useFindOne
in React code:
1export const TodosPage = () => {2 const [{ data, fetching, error }] = useFindMany(api.todo, {3 // turn on the live query mode4 live: true,5 });67 if (fetching) return <div>Loading...</div>;8 if (error) return <div>Oh no... {error.message}</div>;9 return (10 <ul>11 {data.todos.map((todo) => (12 <li>{todo.title}</li>13 ))}14 </ul>15 );16};
1export const TodosPage = () => {2 const [{ data, fetching, error }] = useFindMany(api.todo, {3 // turn on the live query mode4 live: true,5 });67 if (fetching) return <div>Loading...</div>;8 if (error) return <div>Oh no... {error.message}</div>;9 return (10 <ul>11 {data.todos.map((todo) => (12 <li>{todo.title}</li>13 ))}14 </ul>15 );16};
With the live: true
option in place, each user's browser will subscribe to changes from the backend, fetch any changed data, and trigger a re-render of the component whenever a new todo is added, an on-screen todo is deleted, or an on-screen todo's fields are updated.
The live: true
option can be combined with the other options you might pass useFindMany
, like sort
, filter
, and select
. For example, we can show a live view of the most recent unfinished todos:
1export const DashboardPage = () => {2 // get a live view of the most recent 5 unfinished todos3 const [{ data, fetching, error }] = useFindMany(api.todo, {4 live: true,5 filter: { finished: { equals: false }, sort: { createdAt: "Descending" }, first: 5 },6 });78 if (fetching) return <div>Loading...</div>;9 if (error) return <div>Oh no... {error.message}</div>;1011 return (12 <div>13 <h2>Quick todo view</h2>14 <ul>15 {data.todos.map((todo) => (16 <li>{todo.title}</li>17 ))}18 </ul>19 </div>20 );21};
1export const DashboardPage = () => {2 // get a live view of the most recent 5 unfinished todos3 const [{ data, fetching, error }] = useFindMany(api.todo, {4 live: true,5 filter: { finished: { equals: false }, sort: { createdAt: "Descending" }, first: 5 },6 });78 if (fetching) return <div>Loading...</div>;9 if (error) return <div>Oh no... {error.message}</div>;1011 return (12 <div>13 <h2>Quick todo view</h2>14 <ul>15 {data.todos.map((todo) => (16 <li>{todo.title}</li>17 ))}18 </ul>19 </div>20 );21};
Plain API client live queries
If you aren't using React and making API calls with the api
object directly, you can also pass live: true
to subscribe to realtime changes.
Calling a find function on the api
object with live: true
returns an AsyncIterator
object that you can iterate with for await (const ... of)
, instead of just one object.
For example, you'd fetch a todo record with id 123
once by calling findOne
:
const todo = await api.todo.findOne("123");console.log("Todo today: " + todo.text);
const todo = await api.todo.findOne("123");console.log("Todo today: " + todo.text);
You can then fetch a stream of changes to id 123
by calling findOne
with live: true
:
for await (const todo of api.todo.findOne("123", { live: true })) {console.log("Todo changed:", todo.text);}
for await (const todo of api.todo.findOne("123", { live: true })) {console.log("Todo changed:", todo.text);}
The live: true
option is also supported for findMany
, findBy
, and findFirst
calls in the imperative API client.
To find the first page of todo records and subscribe to changes in these records over time, call .findMany({ live: true })
:
1for await (const todos of api.todo.findMany({ live: true, first: 10 })) {2 // todos will be a whole page of 10 records, updated with any changes3 for (const todo of todos) {4 console.log("Todo changed:", todo.text);5 }6}
1for await (const todos of api.todo.findMany({ live: true, first: 10 })) {2 // todos will be a whole page of 10 records, updated with any changes3 for (const todo of todos) {4 console.log("Todo changed:", todo.text);5 }6}
AsyncIterator
for live queries
Gadget's support for live queries in the imperative API client uses AsyncIterator
to power the streaming functionality. AsyncIterator
can be iterated using the JS-builtin for await (const .. of)
syntax, or with helper libraries like repeater
.
If you need to take an action every time your result changes, but also move on to execute more code, you can kick off your async iterator in an async
function that you don't await:
1const streamChanges = async () => {2 // update a DOM element as the data changes3 for await (const todo of api.todo.findOne("123", { live: true })) {4 document.getElementById("#todo").innerHTML = todo.text;5 }6};78void streamChanges();910// do more setup stuff
1const streamChanges = async () => {2 // update a DOM element as the data changes3 for await (const todo of api.todo.findOne("123", { live: true })) {4 document.getElementById("#todo").innerHTML = todo.text;5 }6};78void streamChanges();910// do more setup stuff
Gadget uses AsyncIterator
because it's the only JavaScript-native primitive that supports streaming changes over time, and doesn't require bloating your API client's bundle size with extra libraries you may not use. AsyncIterator
is well supported by modern browsers.
Minimum package versions
Realtime queries are supported by @gadgetinc/react
version 0.14.11
and above, and for Shopify app developers, @gadgetinc/react-shopify-app-bridge
version 0.13.3
and above.
Realtime queries also require you to use the latest version of your @gadget-client
package, which you can update using these instructions.
Supported realtime hooks
The live: true
option can be passed to the following React hooks:
Hook | Notes |
---|---|
useFindMany | Will rerender when any on-screen record changes, or when new records are added or removed from the currently on-screen page |
useFindOne | Will rerender when any field of the record changes, or when the record is deleted |
useFindBy | Will rerender when any field of the returned record changes, or when the record is deleted |
useFindFirst | Will rerender when any field of the returned record changes, when the record is deleted, or when the first record matching the filters changes |
Combining with the select
option
Realtime queries can provide an up-to-date view of deeply nested data by using the select
option to specify which fields to render to. If you use the select
field to show data from related records by selecting through belongs to or has many fields, the related records will also be watched, and your component will re-render when the related data changes as well.
For example, if we have a todo
model with a belongs to relationship to the user
model capturing the assignedUser
, we can show a view of realtime data of both the todo and the user's name with live: true
and select
:
1export const TodosPage = () => {2 const [{ data, fetching, error }] = useFindMany(api.todo, {3 live: true,4 select: {5 id: true,6 title: true,7 assignedUser: {8 name: true,9 },10 },11 });1213 if (fetching) return <div>Loading...</div>;14 if (error) return <div>Oh no... {error.message}</div>;15 return (16 <ul>17 {data.todos.map((todo) => (18 <li>19 {todo.title} {todo.assignedUser.name}20 </li>21 ))}22 </ul>23 );24};
1export const TodosPage = () => {2 const [{ data, fetching, error }] = useFindMany(api.todo, {3 live: true,4 select: {5 id: true,6 title: true,7 assignedUser: {8 name: true,9 },10 },11 });1213 if (fetching) return <div>Loading...</div>;14 if (error) return <div>Oh no... {error.message}</div>;15 return (16 <ul>17 {data.todos.map((todo) => (18 <li>19 {todo.title} {todo.assignedUser.name}20 </li>21 ))}22 </ul>23 );24};
The above React component will re-render when:
- any of the on-screen
todo
records are changed - a new
todo
is added or an existing todo is removed - when the assigned
user
of any of the on-screentodo
records is changed - when the name of any of the assigned
user
records is updated
Combining with other options
Realtime queries support all the same options as the non-live hook calls, like filter
, sort
, search
, first
, before
, last
and after
. If these options are passed, a live: true
hook will provide an up-to-date view of that filtered and sorted page of data. The component will re-render when data is removed from the current page, or when data on the page changes, but not when data on other pages changes.
For example, we can query for live data with a sort, a filter, and a max page size of 5:
1const [{ data, fetching, error }] = useFindMany(api.todo, {2 // use a live query3 live: true,4 // show only todos matching this filter5 filter: {6 finished: { equals: false },7 },8 // show only 5, ordered by most recently created9 sort: { createdAt: "Descending" },10 first: 5,11});
1const [{ data, fetching, error }] = useFindMany(api.todo, {2 // use a live query3 live: true,4 // show only todos matching this filter5 filter: {6 finished: { equals: false },7 },8 // show only 5, ordered by most recently created9 sort: { createdAt: "Descending" },10 first: 5,11});
The above React component will re-render when:
- any of the on-screen
todo
records are changed - a new
todo
is added that is unfinished
The component won't re-render when:
- the 6th most recent todo is changed (or any other less recent one)
- when a finished todo is changed
Result value and hook specifics
See the @gadgetinc/react reference docs for more details on the specifics of each live: true
hook.
Limits
live: true
queries are limited to the same maximum page size as other queries (250 records max). You can still use the pagination options likefirst
andafter
with live queries to show a live view of a specific page of data.live: true
queries only run in environments withWebSocket
support, which includes modern browsers, Node.js and other web runtimes, but excludes sandboxed environments like Shopify Checkout Extensions. If you need realtime query support in these locked-down environments, please get in touch with us on Discord.live: true
queries can fetch computed fields and display them, but they won't automatically re-render when the value of computed fields change. For a work-around on this, see belowlive: true
queries will be pushed new data once every 100ms at mostlive: true
queries will fetch and re-render with the most up-to-date version of the data whenever any one record changes. This means your frontend will get the latest version of a record, but there is no guarantee your frontend will see each version of a record, as some updates may be coalesced into the same frontend update. To guarantee that a particular record state is reacted to, use code in the backend Actions for your models.
Working with computed fields
Realtime queries do not automatically refresh when the values of on-screen computed fields change. Only changes to stored fields for the onscreen records will trigger updates and re-renders.
To work around this limitation, you can fetch data related to the computed fields you're showing that changes at the same time. Because that stored data will be observed, it will trigger refreshes when it changes, which will re-compute the value of your computed fields.
For example, if you have a user
model and a computed field that counts how many post
records exist for that user, you might render a live: true
query that selects a page of users and the postCount
field:
1// won't refresh when the count of posts changes, as no post data is selected2const [{ data, fetching, error }] = useFindMany(api.user, {3 live: true,4 select: {5 id: true,6 postCount: true,7 },8});
1// won't refresh when the count of posts changes, as no post data is selected2const [{ data, fetching, error }] = useFindMany(api.user, {3 live: true,4 select: {5 id: true,6 postCount: true,7 },8});
But, if you also select some of the user's posts (which affect the postCount
), the query will refresh when the posts change, and the postCount
will be re-computed:
1// will refresh when the count of posts changes, because post data is selected2const [{ data, fetching, error }] = useFindMany(api.user, {3 live: true,4 select: {5 id: true,6 postCount: true,7 posts: {8 edges: {9 node: {10 id: true,11 },12 },13 },14 },15});
1// will refresh when the count of posts changes, because post data is selected2const [{ data, fetching, error }] = useFindMany(api.user, {3 live: true,4 select: {5 id: true,6 postCount: true,7 posts: {8 edges: {9 node: {10 id: true,11 },12 },13 },14 },15});
This data may not be necessary for rendering your UI, but it will cause the realtime query to observe the correct data and trigger refreshes of your computed field.
Manually trigger a realtime query
You can use the touch()
function on a record to manually trigger a re-execution of a realtime query when no fields on that record have changed, for example, when you know a computed field has changed and you want to update the displayed value.
1export const run: ActionRun = async ({ api, params }) => {2 // update the grade field on the student record3 await api.student.update(params.studentId, {4 grade: 90,5 });67 // get the class record that the student is in (class has many students)8 const klass = await api.record.findById(params.classId);910 // update the updatedAt field of the record to trigger the realtime query11 // so the computed field for the class grade average is re-calculated12 klass.touch();13 await save(klass);14};
1export const run: ActionRun = async ({ api, params }) => {2 // update the grade field on the student record3 await api.student.update(params.studentId, {4 grade: 90,5 });67 // get the class record that the student is in (class has many students)8 const klass = await api.record.findById(params.classId);910 // update the updatedAt field of the record to trigger the realtime query11 // so the computed field for the class grade average is re-calculated12 klass.touch();13 await save(klass);14};
For more information on touch()
, see the Record API docs.
Billing
Gadget developers are billed for the request time used during each refresh of a realtime query.
For example, if a realtime query starts and takes 75ms to fetch the initial data from the backend, and then refreshes 3 times over the next 5 minutes, and each refresh takes 50ms, the total request time billed would be (75ms + (3 * 50ms)) = 225ms.
Realtime queries are not billed request time for the open websocket that is listening for changes -- only for underlying query re-executions. In the example above, the 5 minutes the query was open would not count towards request time.
Backend implementation
Gadget's auto-generated API implements robust live query support through scalable database changefeeds. Internally, Gadget tracks all changes made to any records by any actions or internal API calls, and publishes these changes to an internal event bus. When a browser starts a realtime query, a WebSocket is opened to Gadget's backend, and Gadget's servers begin watching for changes relevant to specific queries. When a relevant change occurs, the live query runs again in the database, and the difference in result is sent to the browser as a JSON diff patch.
Your backend application doesn't not need to emit change events -- any change made by your backend or frontend code is automatically tracked.
Gadget uses the @live
GraphQL query directive and the @n1ru4l/graphql-live-query ecosystem of packages to implement this functionality on the client.