Views
Views are read-only functions that compute and return results from your tables. Unlike reducers, views do not modify database state - they only query and return data. Views are useful for computing derived data, aggregations, or joining multiple tables before sending results to clients.
Why Use Views?
Views provide several benefits:
- Performance: Views compute results server-side, reducing the amount of data sent to clients
- Encapsulation: Views can hide complex queries behind simple interfaces
- Consistency: Views ensure clients receive consistently formatted data
- Real-time updates: Like tables, views can be subscribed to and automatically update when underlying data changes
Defining Views
Views must be declared as public with an explicit name, and they accept only a context parameter - no user-defined arguments beyond the context type.
- Rust
- C#
- TypeScript
Use the #[spacetimedb::view] macro on a function:
use spacetimedb::{view, ViewContext, AnonymousViewContext, table, SpacetimeType}; use spacetimedb_lib::Identity; #[spacetimedb::table(name = player)] pub struct Player { #[primary_key] #[auto_inc] id: u64, #[unique] identity: Identity, name: String, } #[spacetimedb::table(name = player_level)] pub struct PlayerLevel { #[unique] player_id: u64, #[index(btree)] level: u64, } #[derive(SpacetimeType)] pub struct PlayerAndLevel { id: u64, identity: Identity, name: String, level: u64, } // At-most-one row: return Option<T> #[view(name = my_player, public)] fn my_player(ctx: &ViewContext) -> Option<Player> { ctx.db.player().identity().find(ctx.sender) } // Multiple rows: return Vec<T> #[view(name = players_for_level, public)] fn players_for_level(ctx: &AnonymousViewContext) -> Vec<PlayerAndLevel> { ctx.db .player_level() .level() .filter(2u64) .flat_map(|player| { ctx.db .player() .id() .find(player.player_id) .map(|p| PlayerAndLevel { id: p.id, identity: p.identity, name: p.name, level: player.level, }) }) .collect() }Views can return either Option<T> for at-most-one row or Vec<T> for multiple rows, where T can be a table type or any product type.
Use the [SpacetimeDB.View] attribute on a static method:
using SpacetimeDB; public static partial class Module { [SpacetimeDB.Table] public partial struct Player { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong Id; [SpacetimeDB.Unique] public Identity Identity; public string Name; } [SpacetimeDB.Table] public partial struct PlayerLevel { [SpacetimeDB.Unique] public ulong PlayerId; [SpacetimeDB.Index.BTree] public ulong Level; } [SpacetimeDB.Type] public partial struct PlayerAndLevel { public ulong Id; public Identity Identity; public string Name; public ulong Level; } // At-most-one row: return T? [SpacetimeDB.View(Name = "MyPlayer", Public = true)] public static Player? MyPlayer(ViewContext ctx) { return ctx.Db.Player.Identity.Find(ctx.Sender) as Player; } // Multiple rows: return a list [SpacetimeDB.View(Name = "PlayersForLevel", Public = true)] public static List<PlayerAndLevel> PlayersForLevel(AnonymousViewContext ctx) { var rows = new List<PlayerAndLevel>(); foreach (var player in ctx.Db.PlayerLevel.Level.Filter(1)) { if (ctx.Db.Player.Id.Find(player.PlayerId) is Player p) { var row = new PlayerAndLevel { Id = p.Id, Identity = p.Identity, Name = p.Name, Level = player.Level }; rows.Add(row); } } return rows; } }Views must be static methods and can return either a single row (T?) or a list of rows (List<T> or T[]) where T can be a table type or any product type.
Use the spacetimedb.view or spacetimedb.anonymousView function:
import { schema, table, t } from 'spacetimedb/server'; const players = table( { name: 'players', public: true }, { id: t.u64().primaryKey().autoInc(), identity: t.identity().unique(), name: t.string(), } ); const playerLevels = table( { name: 'player_levels', public: true }, { player_id: t.u64().unique(), level: t.u64().index('btree'), } ); const spacetimedb = schema(players, playerLevels); // At-most-one row: return Option<row> via t.option(...) // Your function may return the row or null spacetimedb.view( { name: 'my_player', public: true }, t.option(players.rowType), (ctx) => { const row = ctx.db.players.identity.find(ctx.sender); return row ?? undefined; } ); // Define a custom row type for the joined result const playerAndLevelRow = t.row('PlayerAndLevel', { id: t.u64(), name: t.string(), level: t.u64(), }); // Multiple rows: return an array of rows via t.array(...) spacetimedb.anonymousView( { name: 'players_for_level', public: true }, t.array(playerAndLevelRow), (ctx) => { const out: Array<{ id: bigint; name: string; level: bigint }> = []; for (const playerLevel of ctx.db.playerLevels.level.filter(2n)) { const p = ctx.db.players.id.find(playerLevel.player_id); if (p) out.push({ id: p.id, name: p.name, level: playerLevel.level }); } return out; } );The handler signature is (ctx) => rows, where rows must be either an array or option of product values.
ViewContext and AnonymousViewContext
Views use one of two context types:
ViewContext: Provides access to the caller'sIdentitythroughctx.sender. Use this when the view depends on who is querying it.AnonymousViewContext: Does not provide caller information. Use this when the view produces the same results regardless of who queries it.
Both contexts provide read-only access to tables and indexes through ctx.db.
- Rust
- C#
- TypeScript
// View that depends on caller identity #[view(name = my_player, public)] fn my_player(ctx: &ViewContext) -> Option<Player> { ctx.db.player().identity().find(ctx.sender) } // View that is the same for all callers #[view(name = players_for_level, public)] fn players_for_level(ctx: &AnonymousViewContext) -> Vec<PlayerAndLevel> { ctx.db .player_level() .level() .filter(2u64) .flat_map(|player| { ctx.db .player() .id() .find(player.player_id) .map(|p| PlayerAndLevel { id: p.id, identity: p.identity, name: p.name, level: player.level, }) }) .collect() }// View that depends on caller identity [SpacetimeDB.View(Name = "MyPlayer", Public = true)] public static Player? MyPlayer(ViewContext ctx) { return ctx.Db.Player.Identity.Find(ctx.Sender) as Player; } // View that is the same for all callers [SpacetimeDB.View(Name = "PlayersForLevel", Public = true)] public static List<PlayerAndLevel> PlayersForLevel(AnonymousViewContext ctx) { var rows = new List<PlayerAndLevel>(); foreach (var player in ctx.Db.PlayerLevel.Level.Filter(1)) { if (ctx.Db.Player.Id.Find(player.PlayerId) is Player p) { var row = new PlayerAndLevel { Id = p.Id, Identity = p.Identity, Name = p.Name, Level = player.Level }; rows.Add(row); } } return rows; }// View that depends on caller identity spacetimedb.view( { name: 'my_player', public: true }, t.option(players.rowType), (ctx) => { const row = ctx.db.players.identity.find(ctx.sender); return row ?? undefined; } ); // View that is the same for all callers spacetimedb.anonymousView( { name: 'players_for_level', public: true }, t.array(playerAndLevelRow), (ctx) => { const out: Array<{ id: bigint; name: string; level: bigint }> = []; for (const playerLevel of ctx.db.playerLevels.level.filter(2n)) { const p = ctx.db.players.id.find(playerLevel.player_id); if (p) out.push({ id: p.id, name: p.name, level: playerLevel.level }); } return out; } );Querying Views
Views can be queried and subscribed to just like normal tables using SQL:
SELECT * FROM my_player; SELECT * FROM players_for_level; When subscribed to, views automatically update when their underlying tables change, providing real-time updates to clients.
Performance Considerations
Views compute results on the server side, which can improve performance by:
- Reducing network traffic by filtering/aggregating before sending data
- Avoiding redundant computation on multiple clients
- Leveraging server-side indexes and query optimization
However, keep in mind that:
- Complex views with multiple joins or aggregations can be expensive to compute
- Views are recomputed whenever their underlying tables change
- Subscriptions to views will receive updates even if the final result doesn't change
Next Steps
- Review Subscriptions for real-time client data access