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