Skip to main content

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.

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's Identity through ctx.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.

Performance: Why AnonymousViewContext Matters

The choice between ViewContext and AnonymousViewContext has significant performance implications.

Anonymous views can be shared across all subscribers. When a view uses AnonymousViewContext, SpacetimeDB knows the result is the same for every client. The database can materialize the view once and serve that same result to all subscribers. When the underlying data changes, it recomputes the view once and broadcasts the update to everyone.

Per-user views require separate computation for each subscriber. When a view uses ViewContext and invokes ctx.sender(), each client potentially sees different data. SpacetimeDB must compute and track the view separately for each subscriber. With 1,000 connected users, that's 1,000 separate view computations and 1,000 separate sets of change tracking.

Prefer AnonymousViewContext when possible. Design your views to be caller-independent when the use case allows. For example:

Use CaseRecommended ContextWhy
Global leaderboardAnonymousViewContextSame top-10 for everyone
Shop inventoryAnonymousViewContextSame items available to all
My inventoryViewContextDifferent per player
My messagesViewContextPrivate to each user
World map regionsAnonymousViewContextGeographic data shared by all nearby players

Design around shared data when you can. Sometimes a small design change lets you use anonymous views. For example, instead of a view that returns "entities near me" (which requires knowing who "me" is), consider views that return "entities in region X". Multiple players in the same region share a single materialized view rather than each having their own.

Example: Per-User View

This view returns the caller's own player data. Each connected client sees different results, so SpacetimeDB must track it separately for each subscriber.

// Per-user: each client sees their own player
spacetimedb.view(
  { name: 'my_player', public: true },
  t.option(players.rowType),
  (ctx) => {
    return ctx.db.players.identity.find(ctx.sender) ?? undefined;
  }
);

Example: High Scores Leaderboard

This view returns players with scores above a threshold. Every client sees the same results, so SpacetimeDB computes it once and shares it across all subscribers. The view uses a btree index on the score column to efficiently find high-scoring players.

const players = table(
  { name: 'players', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    name: t.string(),
    score: t.u64().index('btree'),
  }
);

const spacetimedb = schema(players);

// Shared: same high scorers for all clients
spacetimedb.anonymousView(
  { name: 'high_scorers', public: true },
  t.array(players.rowType),
  (ctx) => {
    // Get all players with score >= 1000 using the btree index
    return Array.from(ctx.db.players.score.filter({ gte: 1000n }));
  }
);

Example: Region-Based Design

Instead of querying "what's near me" (per-user), design your data model so clients subscribe to shared regions. This example shows entities organized by chunk coordinates.

const entity = table(
  { name: 'entity', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    chunkX: t.i32().index('btree'),
    chunkY: t.i32().index('btree'),
    localX: t.f32(),
    localY: t.f32(),
    entityType: t.string(),
  }
);

// Track which chunks each player is subscribed to
const playerChunk = table(
  { name: 'player_chunk', public: true },
  {
    playerId: t.u64().primaryKey(),
    chunkX: t.i32(),
    chunkY: t.i32(),
  }
);

// Shared: all players in chunk (0,0) share this view
spacetimedb.anonymousView(
  { name: 'entities_in_origin_chunk', public: true },
  t.array(entity.rowType),
  (ctx) => {
    // All entities in chunk (0, 0) - shared by everyone viewing this chunk
    return Array.from(ctx.db.entity.chunkX.filter(0))
      .filter(e => e.chunkY === 0);
  }
);

// Per-user: returns entities in the chunk the player is currently in
spacetimedb.view(
  { name: 'entities_in_my_chunk', public: true },
  t.array(entity.rowType),
  (ctx) => {
    const player = ctx.db.players.identity.find(ctx.sender);
    if (!player) return [];

    const chunk = ctx.db.playerChunk.playerId.find(player.id);
    if (!chunk) return [];

    return Array.from(ctx.db.entity.chunkX.filter(chunk.chunkX))
      .filter(e => e.chunkY === chunk.chunkY);
  }
);

The entities_in_origin_chunk view is shared - if 100 players are all looking at chunk (0,0), SpacetimeDB computes it once. The entities_in_my_chunk view requires per-user computation since each player may be in a different chunk.

For games with many players in the same area, the shared approach scales much better. Clients can subscribe to the specific chunk views they need based on their position, and players in the same chunk automatically share the same materialized data.

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.

Why Views Cannot Use .iter()

You may notice that views can only access table data through indexed lookups (.find() and .filter() on indexed columns), not through .iter() which scans all rows. This is a deliberate design choice for performance.

Views are black boxes. View functions are Turing-complete code that SpacetimeDB cannot analyze or optimize. When a view reads from a table, SpacetimeDB tracks the "read set" - which rows the view accessed. If any row in that read set changes, the view's output might have changed, so SpacetimeDB must re-execute the entire view function.

Full table scans create pessimistic read sets. If a view used .iter() to scan an entire table, its read set would include every row in that table. This means any change to any row - even rows unrelated to the view's output - would trigger a complete re-evaluation of the view. For a table with millions of rows, this becomes prohibitively expensive.

Index lookups enable targeted invalidation. When a view uses .find() or .filter() on an indexed column, SpacetimeDB knows exactly which rows the view depends on. If a row outside that set changes, the view doesn't need to be re-evaluated. This keeps view updates fast and predictable.

Why SQL subscriptions can scan. You might wonder why SQL subscription queries can include full table scans while view functions cannot. The difference is that SQL queries are not black boxes - SpacetimeDB can analyze and transform them. The query engine uses incremental evaluation: when rows change, it computes exactly which output rows are affected without re-running the entire query. Think of it like taking the derivative of the query - given a small change in input, compute the small change in output. Since view functions are opaque code, this kind of incremental computation isn't possible.

The tradeoff is acceptable for indexed access. For point lookups (.find()) and small range scans (.filter() on indexed columns), the performance difference between full re-evaluation and incremental evaluation is small. This is why views are limited to indexed access - it's the subset of operations where the black-box limitation doesn't hurt performance.

If you need to aggregate or sort entire tables, consider returning a Query from your view instead. Since queries can be analyzed by the query engine, they support incremental evaluation even when scanning full tables. Alternatively, design your schema so the data you need is accessible through indexes.

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 can be expensive to compute
  • Views are recomputed when rows in their read set change
  • Subscriptions to views will receive updates even if the final result doesn't change

Next Steps