# Computed views [Framework v1.4.0+](https://docs.gadget-canary.xyz/guides/gadget-framework)  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 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](https://docs.gadget-canary.xyz/guides/data-access/gelly), Gadget's expression language. Learn more about Gelly in the [Gelly guide](https://docs.gadget-canary.xyz/guides/data-access/gelly). ## Defining computed views  There are two ways to define computed views in Gadget: a named and predefined `.gelly` file or an inline Gelly snippet. ### Named computed views  Computed views can be created by adding `.gelly` files to your `api/views` directory, or . You may need to create the `api/views` directory. For example, in a todos app, you could display the total count of todos finished each day by creating the `api/views/finishedReport.gelly` file: Then, you can count the number of todos finished each day with a `group by` command: ```gelly // in api/views/finishedReport.gelly view finishedReport { todos { day: dateTrunc(part: "day", date: finishedAt) count(1) [ group by dateTrunc(part: "day", date: finishedAt) where finishedAt != null ] } } ``` ### Inline computed views  You can also run computed views with a Gelly snippet using the `api.view("...")` function: ```typescript const result = await api.view(`{ count(customers) }`); // will log {count: 100} console.log(result); ``` You can also use the `useView` hook to fetch inline computed views in React code by passing a string: ```tsx import { useView } from "@gadgetinc/react"; const [{ data, fetching, error }] = useView(`{ count(customers) }`); // will log {count: 100} console.log(data); ``` Inline computed views can be defined with the same Gelly syntax and used in the same way as named computed views. However, inline views should omit the `view` keyword in the snippet. ### Inline vs named computed views  | Feature | Inline views | Named views | | --- | --- | --- | | **Definition** | Defined ad-hoc using `api.view()` | Saved as `.gelly` files in `api/views` | | **Use Case** | Single-use queries | Reusable, structured queries | | **Typechecking** | Opportunistic using type overloads | Fully-typed in API client | | **Example** | `{ count(customers) }` | `view customerCount { count(customers) }` | ### 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 computed fields. For example, you can compute the total number of `customer` records like so: ```gelly // in api/views/customerCount.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: ```gelly // in api/views/postScore.gelly view postScore { maxScore: max((upvotes - downvotes) * 100) avgScore: avg((upvotes - downvotes) * 100) [ where createdAt > now() - interval("1 month") ] } ``` You can also use data from related models, like computing the top 10 customers by total spend: ```gelly // in api/views/customerTotalSpend.gelly view customerTotalSpend { customers { name sum(orders.totalPrice) [order by sum(orders.totalPrice) desc limit 10] } } ``` ### Computed view scopes and namespaces  A view's scope determines what models it has access to, how models are referenced in your queries, and how the view is organized in your API and API client. Scope is determined by a view's namespace, and the namespace is determined by the view's location in your `api` folder. A view created at `api/views/someView.gelly` is in the root namespace, and has access to all models in your application. It is added to your API and `api` client at `api.someView`. 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. The view will only have access to models in the same namespace or models in any sub-namespaces. For example, you could add a computed view in a directory called `reports`: ```gelly // in api/views/reports/customerCount.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} ``` This also means that the `customer` model must be in the `reports` namespace as well, or the view will not be able to access it. [Read more about model namespaces](https://docs.gadget-canary.xyz/guides/models/namespaces). When you put a computed view in a folder, the view executes **within** that namespace. You must refer to other models in that namespace directly, instead of repeating the namespace in your query. For example, let's say you have an `analytics` namespace, with a model named `pageView` in this namespace, living at `api/models/analytics/pageView`. A root-level computed view at `api/views/pageViewCount.gelly` would refer to this model as `analytics.pageView`: ```gelly // in api/views/pageViewCount.gelly view { count(analytics.pageView) } ``` You could add the view to the `analytics` namespace by creating a folder at `api/views/analytics` and placing the view file there. A namespaced computed view at `api/views/analytics/pageViewCount.gelly` would refer to this model as `pageView`, because it is within the same namespace: ```gelly // in api/views/analytics/pageViewCount.gelly view { count(pageView) } ``` A view's scope does not impact relationship traversals. You can always traverse relationships to other models, even if they are in different namespaces. #### Model namespaced views  You can also create `views` directories within your model directories and they'll be added to that model's API namespace. Views inside model directories reference fields from their parent model directory directly, instead of having to select the model explicitly. For example, this `summary` view can access `customer` fields directly without stating that the fields are from the `customer` model: ```gelly // in api/models/customer/views/summary.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) } ``` Model-namespaced views are invoked by calling the view on the model's namespace in your 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); ``` You can also use the `useView` hook to fetch model-namespaced views in React code: ```typescript const [{ data, fetching, error }] = useView(api.customer.summary); // will log {name: "John Doe", createdAt: "2025-01-01", orderCount: 10, minOrderAmount: 100, maxOrderAmount: 1000} console.log(data); ``` Directly referencing fields reduces the verbosity of your views, and removes the need to dig out the data you need on the frontend. Model-namespaced views are optional, in that you can compute the same data with a root-level view or model-namespaced view, so its up to you how you'd like to organize your views. #### Model namespaced inline views  Inline views can also be run against a model using the `api..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); ``` ### Query variables  Computed views can list variables to accept from API callers. When an API call is made, Gadget will validate the type of the variables, and pass them into your view's computation. This allows you to build re-usable views that filter, sort, or aggregate data in different ways depending on how they are called. For example, you can use variables to limit your view to look at a certain time range, or exclude 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 always 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: ```gelly // in api/views/customerCount.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 ``` In React code, pass variables to the `useView` hook as the second argument: ```tsx 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: ```gelly // in api/views/revenueReport.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} ``` 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} ``` or in React code using the `useView` hook: ```tsx 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); ``` You should always pass dynamic values to inline computed views using the `variables` parameter. It allows for better escaping of values with spaces or special characters, and improved performance under the hood. Gadget also provides type support for your inline view parameters. ## Querying a computed view  Computed views can be queried using your API client. 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: ```gelly // in api/views/customerCount.gelly view customerCount { count(customers) } ``` Then, you can query this field using your API client: ```typescript const customerCount = await api.customerCount(); // will log {count: 100} ``` ### From the frontend  The [`useView` React hook](https://docs.gadget-canary.xyz/reference/react#useview) can also be used to query computed views from within React components: ```tsx import { useView } from "@gadgetinc/react"; export const MyComponent = () => { const [{ data, fetching, error }] = useView(api.finishedReport); if (fetching) return
Loading...
; if (error) return
Error: {error.message}
; return ( ); }; ``` If you are building with Remix or React Router, you can also call computed views from your route `loader` functions to fetch data on the server side: ```tsx export const loader = async ({ context, params }) => { const report = context.api.report.finishedReport(); return { report, }; }; ``` ### From the backend  You can query computed views from your app's actions and HTTP routes using the `api` client. For example, you can use a computed view in a backend action to fetch aggregated data and process it further: ```typescript import { applyParams, save } from "gadget-server"; import type { ActionOptions } from "gadget-server"; export const run: ActionRun = async ({ api, record, params, logger }) => { // Fetch data from a computed view const finishedReport = await api.finishedReport(); // Process the finished report data logger.info({ finishedReport }, "logging is processing... right?"); // Apply params and save the record applyParams({ record, params }); await save({ record }); }; export const options: ActionOptions = { actionType: "update", }; ``` ### Pagination  Computed views do not paginate results by default, and Gadget will attempt to return all query results in a single response. This can be slow and may exceed the maximum result set size, resulting in a `GGT_GELLY_RESULT_TRUNCATED` error. To avoid this, you must use the `limit` and `offset` keywords in your Gelly snippets to paginate your results. `limit` controls the maximum number of results returned, while `offset` specifies the starting point for the results. For example, to paginate the results of a `leaderboard` computed view, add `$limit` and `$offset` variables to your Gelly snippet, and pass them to the `limit` and `offset` Gelly commands: ```gelly // in api/views/leaderboard.gelly view ($limit: Int, $offset: Int) { customers { id name totalSpend [ order by totalSpend desc limit $limit offset $offset ] } } ``` When you call the view, pass in values for the `$limit` and `$offset` variables: ```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); ``` #### 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: ```gelly // in api/views/customerDetails.gelly view { customers { id name [limit 100] } } ``` The maximum result set size limit applies to the **final result** of your query, but not to intermediate records processed within the query. It is safe to examine a large number of records within your query, just not safe 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](https://docs.gadget-canary.xyz/api/example-app/development/internal-api#atomically-increment-a-record-field) 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 React Router/Remix 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: ```tsx 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. Alternatively, you can always wrap a computed view in an action, then set permissions on the action to control access. ### 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 fields 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: ```gelly // in api/views/revenueReport.gelly // Daily revenue view { shopifyOrders { day: datePart("month", date: createdAt) revenue: sum(totalPrice) [ where createdAt > now() - interval("30 days") group 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); ``` ### 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: ```gelly // in api/views/leads/stages.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); ``` ## 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. As a rule of thumb, Gadget supports good performance for up to 50M records processed by a computed view, but beyond this number, performance will degrade. 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.