Computed views beta 

Computed views are read-only queries that return data from your backend for customized use cases beyond the normal find and findMany API calls. Computed views can filter, transform, and aggregate data from many records, which makes them useful for building things like dashboards, leaderboards, reports, and other custom queries.

Computed views are currently in beta, and require being granted access by a member of the Gadget team.

Computed views require using the @gadgetinc/react package version 0.21.0 or later.

Computed views shift the burden of query performance from you onto Gadget. For queries that aggregate data, like counts and sums, or build reports across many records, Gadget recommends leveraging computed views. By using computed views, you no longer have to:

  • aggregate data ahead of time when you write it
  • remember to re-run the query when any of its inputs change (e.g. if a record is created or updated)
  • keep the query performant or manage database indexes

Queries to the Gadget database via computed views are written in Gelly, Gadget's expression language. Gelly is a simple and easy-to-learn language that closely resembles SQL and GraphQL. Learn more about Gelly in the Gelly guide.

Named computed views 

Computed views can be created by adding .gelly files to your api/views directory, or other views subdirectories. For example, if you wanted to display the total count of todos finished each day in a todo app across all projects, you could create a computed view in your api/views/finishedReport.gelly file, and compute a count of todos grouped by day:

api/views/finishedReport.gelly
gelly
view finishedReport { todos { day: dateTrunc(part: "day", date: finishedAt) count(1) [ group by dateTrunc(part: "day", date: finishedAt) where finishedAt != null ] } }

Then, you can fetch the finishedReport view from your API in backend code like actions or react-router loaders:

TypeScript
const finishedReport = await api.finishedReport(); // will log {todos: [{finishedAt: "2025-01-01", count: 10}, {finishedAt: "2025-01-02", count: 20}]} console.log(finishedReport);
const finishedReport = await api.finishedReport(); // will log {todos: [{finishedAt: "2025-01-01", count: 10}, {finishedAt: "2025-01-02", count: 20}]} console.log(finishedReport);

You can also fetch the finishedReport view from your API in React code:

React (TypeScript)
import { useView } from "@gadgetinc/react"; export const MyComponent = () => { const [{ data, fetching, error }] = useView(api.finishedReport); if (fetching) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {data.todos.map((todo) => ( <li key={todo.day}> {todo.day}: {todo.count} </li> ))} </ul> ); };
import { useView } from "@gadgetinc/react"; export const MyComponent = () => { const [{ data, fetching, error }] = useView(api.finishedReport); if (fetching) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {data.todos.map((todo) => ( <li key={todo.day}> {todo.day}: {todo.count} </li> ))} </ul> ); };

Inline computed views 

In addition to creating named computed views server-side in your api folder, you can also run computed views with an string Gelly snippet using the api.view() function:

TypeScript
const result = await api.view(`{ count(customers) }`); // will log {count: 100} console.log(result);
const result = await api.view(`{ count(customers) }`); // will log {count: 100} console.log(result);

Or in React code using the useView hook:

React (TypeScript)
const [{ data, fetching, error }] = useView(`{ count(customers) }`); // will log {count: 100} console.log(data);
const [{ data, fetching, error }] = useView(`{ count(customers) }`); // will log {count: 100} console.log(data);

Inline computed views can be used in the same way as named computed views, and can use the same Gelly syntax. Inline views should omit the view keyword in the snippet for brevity.

Computed views vs computed fields 

Computed fields compute one value for each record of a model. Computed views return one overall result, which can be whatever shape is most useful to you. When a computed field is run, it's run in the context of exactly one record, and when a computed view is run, its run in the context of all records. computed fields can also easily be selected at the same time as other fields of your models, whereas computed views must be fetched explicitly on their own.

Use computed fields when your computation is naturally anchored to one record of one model in particular, so it's easily available for each record. Use computed views otherwise.

Snippet syntax 

Computed view Gelly snippets define a new view with the view keyword, and then select some fields from your application's schema. This expression can use fields from all your models, including other computed fields.

For example, we can compute the total number of customer records with the following Gelly snippet:

api/views/customerCount.gelly
gelly
view customerCount { count(customers) }

Computed views can do arithmetic as well, like computing a post's score from its upvotes and downvotes, and returning the average and max score for posts in the past month:

api/views/postScore.gelly
gelly
view postScore { maxScore: max((upvotes - downvotes) * 100) avgScore: avg((upvotes - downvotes) * 100) [ where createdAt > now() - interval("1 month") ] }

Computed views can use data from related models, like computing the top 10 customers by total spend:

api/views/customerTotalSpend.gelly
gelly
view customerTotalSpend { customers { name sum(orders.totalPrice) [order by sum(orders.totalPrice) desc limit 10] } }

Using a computed view 

You can create and use a computed view in three steps:

  1. Add the view to your backend in the api/views directory (or a namespaced view directory)
  2. Define the code for the view in the .gelly snippet file created for your view
  3. Query the computed view in your API along with any other fields you like.

For example, say your app has a model named customer. You can add a new computed view to your API called customerCount with this Gelly snippet:

api/views/customerCount.gelly
gelly
view customerCount { count(customers) }

Then, we can query this field in our API like so:

TypeScript
const customerCount = await api.customerCount(); // will log {count: 100}
const customerCount = await api.customerCount(); // will log {count: 100}

Computed view Gelly snippets can support a wide variety of operators and functions, for more information refer to the Gelly reference.

Accessing computed views on the frontend through your API 

Computed views can be accessed the same way as all your other fields from the frontend: using your API client or your auto-generated GraphQL API.

const viewResult = await api.someView({ someVariable: "someValue" }); // => value from the backend console.log(viewResult);
const [{data, fetching, error}] = useView(api.someView, {someVariable: "someValue"}); // => value from the backend console.log(data);
query { someView(someVariable: "someValue") }
const viewResult = await api.someView({ someVariable: "someValue" }); // => value from the backend console.log(viewResult);
const [{data, fetching, error}] = useView(api.someView, {someVariable: "someValue"}); // => value from the backend console.log(data);

Namespaced computed views 

For better organization of your backend API, you can choose to place your computed views into model directories or namespace directories in your api folder. When you create a folder within the api/views directory, each file in that subdirectory will be added to your api and api client under that namespace, and it will execute within the context of that namespace.

For example, if you wanted to place your computed views in a directory called reports, you could do so with the following Gelly snippet:

api/views/reports/customerCount.gelly
gelly
view customerCount { count(customers) }

With this view in the reports namespace, it will be added to your API and API client at api.reports.customerCount:

TypeScript
const customerCount = await api.reports.customerCount(); // will log {count: 100}
const customerCount = await api.reports.customerCount(); // will log {count: 100}

You can also create namespaced views within your models' directories, and they'll be added to the model's API namespace. Views inside model directories reference fields from their parent model directory directly, instead of having to select the model explicitly.

api/models/customer/views/summary.gelly
gelly
view { # directly select fields from the model (instead of { customer { name } }) name createdAt # directly use related fields in expressions orderCount: count(orders) minOrderAmount: min(orders.totalPrice) maxOrderAmount: max(orders.totalPrice) }

With a view within a model's namespace, it will be added to the model's namespace in your API and API client:

TypeScript
const customerSummary = await api.customer.summary(); // will log {name: "John Doe", createdAt: "2025-01-01", orderCount: 10, minOrderAmount: 100, maxOrderAmount: 1000} console.log(customerSummary);
const customerSummary = await api.customer.summary(); // will log {name: "John Doe", createdAt: "2025-01-01", orderCount: 10, minOrderAmount: 100, maxOrderAmount: 1000} console.log(customerSummary);

Namespaced inline views 

Inline views can also be run against a model using the api.<model>.view() function. For example, if you wanted to run a quick count of customers, you could do so with the following:

TypeScript
const customerCount = await api.customer.view(`{ count(id) }`); // will log {count: 100} console.log(customerCount);
const customerCount = await api.customer.view(`{ count(id) }`); // will log {count: 100} console.log(customerCount);

Query variables 

Computed views can list the variables they accept in their Gelly snippet, and these variables can be passed in when the view is called on the frontend or backend. This allows you to build re-usable views that filter, sort, or aggregate data in different ways depending on how they are called, like limiting to a certain time range, or excluding records in a particular state. Computed view variables are defined at the top of your Gelly snippet using $variables: SomeType syntax, similar to GraphQL variables. All variables are optional, and if a variable is not provided when the view is called, it will be null in your view's computation.

For example, say you have a customer model with a status field, and you want to build a computed view that can filter customers by their status. You could define the view with a variable like this:

api/views/customerCount.gelly
gelly
view customerCount($status: String) { count(customers, where: status == $status) }

When you call the view, you can pass in a variable for the $status parameter:

TypeScript
const customerCount = await api.customerCount({ status: "active" }); // will log {count: 50}, only counting the active customers
const customerCount = await api.customerCount({ status: "active" }); // will log {count: 50}, only counting the active customers

In React code, pass variables to the useView hook as the second argument:

React (TypeScript)
const [{ data, fetching, error }] = useView(api.customerCount, { status: "active" }); // will log {count: 50}, only counting the active customers console.log(data);
const [{ data, fetching, error }] = useView(api.customerCount, { status: "active" }); // will log {count: 50}, only counting the active customers console.log(data);

You can accept multiple variables as well. For example, you can aggregate records with a date range by creating $startDate and $endDate variables:

api/views/revenueReport.gelly
gelly
view ($startDate: Date, $endDate: Date) { sum(orders.totalPrice, where: orders.createdAt > $startDate && orders.createdAt < $endDate) }

When you call the view, you can pass in variables for the $startDate and $endDate parameters:

TypeScript
const revenueReport = await api.revenueReport({ startDate: new Date("2024-01-01"), endDate: new Date("2024-01-31"), }); // will log {sum: 10000}
const revenueReport = await api.revenueReport({ startDate: new Date("2024-01-01"), endDate: new Date("2024-01-31"), }); // will log {sum: 10000}

You can also pass variables to inline views with the api.view() function:

TypeScript
const revenueReport = await api.view( `($startDate: Date, $endDate: Date) { sum(orders.totalPrice, where: orders.createdAt > $startDate && orders.createdAt < $endDate) }`, { startDate: new Date("2024-01-01"), endDate: new Date("2024-01-31") } ); // will log {sum: 10000}
const revenueReport = await api.view( `($startDate: Date, $endDate: Date) { sum(orders.totalPrice, where: orders.createdAt > $startDate && orders.createdAt < $endDate) }`, { startDate: new Date("2024-01-01"), endDate: new Date("2024-01-31") } ); // will log {sum: 10000}

or in React code using the useView hook:

React (TypeScript)
const [{ data, fetching, error }] = useView( `($startDate: Date, $endDate: Date) { sum(orders.totalPrice, where: orders.createdAt > $startDate && orders.createdAt < $endDate) }`, { startDate: new Date("2024-01-01"), endDate: new Date("2024-01-31") } ); // will log {sum: 10000} console.log(data);
const [{ data, fetching, error }] = useView( `($startDate: Date, $endDate: Date) { sum(orders.totalPrice, where: orders.createdAt > $startDate && orders.createdAt < $endDate) }`, { startDate: new Date("2024-01-01"), endDate: new Date("2024-01-31") } ); // will log {sum: 10000} console.log(data);

Pagination 

You can use the limit and offset keywords in your Gelly snippets to paginate your results. Computed views don't paginate your results automatically. If you query returns many results, Gadget will try to return them all at once, which can be slow or exceed the maximum result set size. Exceeding the maximum result set size will throw a GGT_GELLY_RESULT_TRUNCATED error. To return small enough pages of results and avoid this error, ensure you limit the size of your result set using the limit keyword in your Gelly commands.

For example, to allow paginating a leaderboard computed view, add a $limit and $offset variable to your Gelly snippet, and pass them to the limit and offset commands:

api/views/leaderboard.gelly
gelly
view ($limit: Int, $offset: Int) { customers { id name totalSpend [ order by totalSpend desc limit $limit offset $offset ] } }

When you call the view, you can pass in variables for the $limit and $offset parameters:

TypeScript
const pageOne = await api.leaderboard({ limit: 2, offset: 0 }); // will log [{id: 1, name: "John Doe", totalSpend: 1000}, {id: 2, name: "Jane Doe", totalSpend: 900}] console.log(pageOne); const pageTwo = await api.leaderboard({ limit: 3, offset: 2 }); // will log [{id: 3, name: "John Doe", totalSpend: 800}, {id: 4, name: "Jane Doe", totalSpend: 700}, {id: 5, name: "John Smith", totalSpend: 600}] console.log(pageTwo);
const pageOne = await api.leaderboard({ limit: 2, offset: 0 }); // will log [{id: 1, name: "John Doe", totalSpend: 1000}, {id: 2, name: "Jane Doe", totalSpend: 900}] console.log(pageOne); const pageTwo = await api.leaderboard({ limit: 3, offset: 2 }); // will log [{id: 3, name: "John Doe", totalSpend: 800}, {id: 4, name: "Jane Doe", totalSpend: 700}, {id: 5, name: "John Smith", totalSpend: 600}] console.log(pageTwo);

Maximum result set size 

Computed views can return a maximum of 10000 results at any one level of your query. If the 10000 result limit is exceeded, Gadget will throw a GGT_GELLY_RESULT_TRUNCATED error with a path attribute showing you the path to the expression in your query that exceeds the limit.

To avoid this error, you can limit the size of your result set using the limit keyword in your Gelly snippet. For example, to limit the result set to 100 results, set a limit in your Gelly commands:

api/views/customerDetails.gelly
gelly
view { customers { id name [limit 100] } }

The maximum result set size limit applies to the final result of your query, not the records processed within the query. So, it is ok to use a large number of records within the query, just not ok to return them all. Aggregations over models with millions of records are a supported use case for computed views.

Data consistency 

Computed views are computed on the fly when requested on a secondary read replica of your application's main database. This means they operate on a slightly delayed version of your application's data, and cannot write any data themselves. Gadget maintains a 95th percentile read replica lag of under 200ms, so the delay is often imperceptible, but in rare circumstances the delay can grow to 10s of seconds.

Your app uses a read replica to ensure that expensive computations don't overload the primary database and cause other queries to slow down, and safely allows you to create computed views that do big or long computations.

Usually, this delay doesn't matter, but if you need transactional guarantees, you can't use computed views within your transactions to gather data for your transaction, because it won't reflect the latest changes. Instead, you must use normal stored fields or atomic operations to read and write data within your transaction.

Access control and tenancy 

Computed views execute with the permissions of the calling API client, and by default, see only the data that API client has access to. If you make a request to a computed view from your app's frontend, your model filters will be applied, and only the records that the API client has access to will be included in the computation.

Model filters are applied before the computations in the view. So, if your view counts records, it will only count the records that the API client has access to, and not all the records in the database.

For example, if you have a order model, and a model filter set up on the order model to only include orders for the current customer, a view like { count(orders) } will only count the orders for the current customer, and not all the orders in the database.

Escaping the current access control context 

By default, callers can only see data they have access to within computed views. However, you may need to escape the current access control context to do a computation across all records, including those that the API client does not have access to. If this is secure for your application, you can execute a view with full access to the database using api.actAsAdmin(). You can only call api.actAsAdmin() within server side code, which includes your app's actions, server-side HTTP routes, and client-side route loader functions.

For example, in a route loader function, you can call api.actAsAdmin() to escape the current access control context and run a view with full access to the database:

web/routes/_user.todos.tsx
React (TypeScript)
import type { Route } from "./+types/_user.todos"; export const loader = async ({ context, request }: Route.LoaderArgs) => { // The `api` client will act as the current session by default, which only returns data for the logged in user // Add the `actAsAdmin` if you want to return all data instead const leaderboard = await context.api.actAsAdmin.leaderboard(); // return the data you want to pass to the frontend return { leaderboard, }; };
import type { Route } from "./+types/_user.todos"; export const loader = async ({ context, request }: Route.LoaderArgs) => { // The `api` client will act as the current session by default, which only returns data for the logged in user // Add the `actAsAdmin` if you want to return all data instead const leaderboard = await context.api.actAsAdmin.leaderboard(); // return the data you want to pass to the frontend return { leaderboard, }; };

Leaderboards are good examples of when you may want to escape the default access control for computed views. You may not want to give a user access to all the data for all users to read generally, but in order to view the leaderboard, that data has to be counted and scored. The secure way to implement this is to not grant users access to other users data, but to instead run the leaderboard computed view with api.actAsAdmin().

For security, you can't use .actAsAdmin() in client-side code, which means you can't use it with the useView React hook. Instead, you must use route loader functions or other server-side code to run views with full access to the database.

Access control in computed fields vs computed views 

Within a computed view, the visible data is the same as the data that the API client has access to. Within a computed fields, all data is always visible, regardless of who is accessing the data. This is for two reasons:

  • for secure defaults, computed views default to only showing data that the API client has access to, instead of allowing access control escapes by default
  • but for performance, computed views have only one value, instead of different values depending on who is accessing the data

Example computed views 

Use case: revenue dashboard 

You can compute time series aggregates over data in your Gadget models with computed views to build user-facing dashboards. For example, we can show a Shopify merchant's revenue over time using a computed view that aggregates order revenue with a daily grouping:

api/views/revenueReport.gelly
gelly
// Daily revenue view { shopifyOrders { day: datePart("month", date: createdAt) revenue: sum(totalPrice) [ where createdAt > now() - interval("30 days") group by day order by day ] } }

You can then query this view to get the revenue for each day in the last 30 days:

TypeScript
const revenueReport = await api.revenueReport(); // will log [{day: "2024-01-01", revenue: 1000}, {day: "2024-01-02", revenue: 2000}, ...] console.log(revenueReport);
const revenueReport = await api.revenueReport(); // will log [{day: "2024-01-01", revenue: 1000}, {day: "2024-01-02", revenue: 2000}, ...] console.log(revenueReport);

Use case: CRM pipeline health 

You can compute overall counts of records in particular states with computed views to build user-facing homepages or dashboards. For example, we can show a CRM merchant's pipeline health by counting the number of leads in each stage of the pipeline:

api/views/leads/stages.gelly
gelly
view { leads { stage count(id) [group by stage] } }

You can then query this view to get the number of leads in each stage of the pipeline:

TypeScript
const pipelineHealth = await api.lead.stages(); // will log [{stage: "lead", count: 10}, {stage: "opportunity", count: 5}, {stage: "customer", count: 3}] console.log(pipelineHealth);
const pipelineHealth = await api.lead.stages(); // will log [{stage: "lead", count: 10}, {stage: "opportunity", count: 5}, {stage: "customer", count: 3}] console.log(pipelineHealth);

Pricing 

Invoking a computed view or excluding a computed view from your API calls in Gadget does not change the price of the API call. API calls are charged for the number of records returned, not the number of fields returned or the number of records scanned in the database. API calls will still be charged for database reads based on the number of records returned.

For more information on Gadget's billing, see the Usage and billing guide.

Performance 

Gadget executes computed views under the hood using SQL statements doing read-time aggregation. This means that if your computed views is aggregating over a significant number of records, it can take some time for your hosted database the execute the query. Gadget automatically optimizes the layout and indexes in your database to ensure your computed views are computed quickly, but they can certainly add time to the duration of your API calls.

For dedicated, high-scale analytics and reporting use cases, Gadget recommends using a dedicated analytics database like Google BigQuery or ClickHouse.

Rate limits 

Computed view query executions are rate limited at 60s of total query execution time per 10s of wall time, per environment. If the rate is exceeded queries will return a GGT_RATE_LIMIT_EXCEEDED error with the indication of how much time is left for the query budget to be refilled.

Higher rate limits are available upon request. Please contact Gadget support to discuss your use case.

Was this page helpful?