State View — List Authors
State View — List Authors
Section titled “State View — List Authors”This tutorial builds the List Authors slice of the Library system. It is a State View — the read side of Event Modeling.
Events recorded by the Register Author slice are projected into a purpose-built read model, exposed through an observable query, and rendered in a live-updating page.
By the end you will have:
- An
Authorread model automatically projected fromAuthorRegisteredevents - An
AllAuthorsobservable query that pushes updates to the frontend in real time - An
Authorslisting page usingDataPagefrom@cratis/components
What is a State View?
Section titled “What is a State View?”In Event Modeling terms, a State View slice answers the question: “What does the user need to see right now?”
The shape is:
- Events that have been recorded are projected into a Read Model
- The read model is a purpose-built view — not a generic table, but exactly the shape a specific UI needs
- The frontend queries the read model and renders it
This is the read side of CQRS. The read model never writes to the event log — it only reads from it. You can have as many projections as you like from the same events, each optimised for a different query. If you change what data the UI needs, you change the projection and replay; the event log is untouched.
One of Chronicle’s most powerful features is that projections are rewindable: drop the read model collection, replay the events, and the read model is reconstructed perfectly. Your data is always recoverable.
Folder Structure
Section titled “Folder Structure”Features/└── Authors/ ├── AuthorId.cs ← Shared concept (from the State Change slice) ├── AuthorName.cs ← Shared concept (from the State Change slice) └── Listing/ ├── Listing.cs ← Read model + projection + query (ALL backend) └── Listing.tsx ← React component for the listing pageStep 1 — The Backend Slice
Section titled “Step 1 — The Backend Slice”All backend artefacts for this slice live in Listing.cs.
using Cratis.Chronicle.Events.Projections;using Cratis.Chronicle.Projections;using Cratis.Chronicle.Read;using MongoDB.Driver;using Cratis.Extensions.MongoDB;using Library.Authors.Registration;using Library.Authors;
namespace Library.Authors.Listing;
// ─── Read Model ───────────────────────────────────────────────────────────────
[ReadModel][FromEvent<AuthorRegistered>]public record Author( [Key] AuthorId Id, AuthorName FirstName, AuthorName LastName){ public static ISubject<IEnumerable<Author>> AllAuthors( IMongoCollection<Author> collection) => collection.Observe();}What is happening here?
Section titled “What is happening here?”[ReadModel] registers the record with Chronicle as a MongoDB-backed projection target. Chronicle automatically creates and maintains the collection. You never write a MongoDB query to update it — Chronicle does that from the event stream.
[FromEvent<AuthorRegistered>] is a projection shorthand: “when an AuthorRegistered event is appended, map its properties to this read model using convention.” Chronicle matches properties by name. FirstName on the event maps to FirstName on the read model, LastName to LastName. No explicit mapping code needed.
[Key] on AuthorId tells Chronicle which property is the read model’s primary key, and how to correlate events to read model instances. Because RegisterAuthor.Handle() returns an AuthorId as the event source identity, Chronicle stores the AuthorRegistered event under that ID — and the projection updates the Author document with the same ID.
AllAuthors is a static query method. Method parameters are automatically resolved from DI — IMongoCollection<Author> is provided because the type is a [ReadModel]. The return type ISubject<IEnumerable<Author>> is a reactive observable query: the frontend receives the current list immediately, and then receives a new emission whenever any document in the collection changes. No polling. No WebSockets to configure manually.
Run
dotnet buildafter savingListing.cs. This generates theAllAuthors.tsquery proxy and theAuthor.tsmodel type via Arc’s proxy generation used by the frontend component.
Step 2 — Projection Mapping Options
Section titled “Step 2 — Projection Mapping Options”The example above uses attribute-based convention mapping, which works when event and read model property names match. For cases where they differ, or where you need arithmetic operations, use the full attribute vocabulary:
| Attribute | What it does |
|---|---|
[FromEvent<T>] on the record | Auto-map all matching properties from event T |
[FromEvent<T>(key: nameof(...))] | Map from a specific event property as the key |
[SetFrom<T>] | Explicit property mapping from a named event |
[AddFrom<T>] / [SubtractFrom<T>] | Accumulate values from an event |
[Increment<T>] / [Decrement<T>] | Increment or decrement a counter |
[Count<T>] | Count occurrences of an event type |
[RemovedWith<T>] | Remove the read model document when this event occurs |
[Join<T>] | Join properties from a second event stream |
For the most complex cases — conditional updates, aggregations, computed properties — use the fluent IProjectionFor<T> interface instead:
public class AuthorProjection : IProjectionFor<Author>{ public void Define(IProjectionBuilderFor<Author> builder) => builder .From<AuthorRegistered>();}AutoMap is on by default. .From<AuthorRegistered>() alone is enough when names match.
Step 3 — The React Component
Section titled “Step 3 — The React Component”import { useState } from 'react';import { DialogResult, useDialog } from '@cratis/arc.react/dialogs';import { CommandResult } from '@cratis/arc/commands';import { Column } from 'primereact/column';import { DataPage, MenuItem, MenuItems, Columns } from '@cratis/components';import { AllAuthors } from './queries/AllAuthors';import { AddAuthor, type RegisterAuthorResponse } from '../Registration/AddAuthor';import type { Author } from './queries/Author';
export const Listing = () => { const [AddAuthorDialog, showAddAuthor] = useDialog<CommandResult<RegisterAuthorResponse>>(AddAuthor); const [selected, setSelected] = useState<Author | undefined>(undefined);
return ( <> <DataPage title="Authors" query={AllAuthors} emptyMessage="No authors registered yet" dataKey="id" onSelectionChange={setSelected} > <MenuItems> <MenuItem label="Add Author" icon="pi pi-plus" command={async () => { const [dialogResult] = await showAddAuthor(); // DataPage auto-refreshes via the observable query }} /> </MenuItems>
<Columns> <Column field="firstName" header="First Name" sortable /> <Column field="lastName" header="Last Name" sortable /> </Columns> </DataPage>
<AddAuthorDialog /> </> );};What is happening here?
Section titled “What is happening here?”AllAuthors is the generated query proxy — an IObservableQueryFor<Author[]> implementation. DataPage calls it once, subscribes to its observable, and re-renders whenever the backend pushes a new list. If another user registers an author in another browser tab, this list updates without any manual refresh.
DataPage from @cratis/components provides the complete page chrome: title, action menu bar, a data table with sorting and filtering, and pagination. You declare columns as children using PrimeReact’s Column and the component does everything else.
useDialog<CommandResult<RegisterAuthorResponse>>(AddAuthor) from @cratis/arc.react/dialogs returns a tuple: AddAuthorDialog is a wrapper component that you render in JSX, and showAddAuthor is an async function that opens the dialog and returns [dialogResult, commandResult] when it closes. The type parameter CommandResult<RegisterAuthorResponse> flows end-to-end — the dialog uses useDialogContext with the same type, so close-data is fully typed.
MenuItem in the MenuItems slot adds an action to the toolbar. The command handler awaits the dialog — you can inspect the result if needed, but since DataPage subscribes to the observable query, the list updates automatically after a successful registration.
The AddAuthor component is imported from the Registration slice — slices within the same feature compose naturally because they share the AuthorId and AuthorName concepts from the parent folder.
Step 4 — Wiring to the Feature Page
Section titled “Step 4 — Wiring to the Feature Page”Each feature has a composition page that assembles its slices.
import { Listing } from './Listing/Listing';
export const Authors = () => <Listing />;In larger features this page will host a navigation menu that switches between slices. For now, the listing is the whole feature.
Step 5 — Registering the Route
Section titled “Step 5 — Registering the Route”Register Authors in your application’s router:
// App.tsx (ASP.NET Core Vite integration)import { Route } from 'react-router-dom';import { Authors } from './Features/Authors/Authors';
// ...inside your <Routes><Route path="/authors" element={<Authors />} />Summary
Section titled “Summary”| Layer | Artifact | Technology |
|---|---|---|
| Read model | Author record | Chronicle [ReadModel] + [FromEvent<T>] |
| Query | AllAuthors static method | Chronicle ISubject<IEnumerable<T>> |
| Generated proxy | AllAuthors.ts | Arc proxy generation |
| Listing page | Listing.tsx | @cratis/components DataPage |
The read model and its query fit in one record. The projection is zero-configuration convention mapping. The frontend subscribes to a live stream, not a static snapshot. The UI automatically reflects every state change appended anywhere in the system — including changes from the Register Author slice.