State View — list authors
The librarian has registered a few authors and now wants to see them: an Authors page with each author’s first and last name, sortable, with an Add Author action on the toolbar. The events recorded by the register-an-author slice already hold those names. This State View projects them into a read model shaped for that page and queries it. The page updates as authors are registered, with every backend (the TypeScript one a source preview). How each backend observes the list is one of the differences the series’ backend table lists.
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:
- Recorded events build a Read Model through a projection or reducer
- The read model is a purpose-built view in the exact shape one specific UI needs, instead of a generic table
- The frontend queries the read model and renders it
This is the read side of CQRS. The read model only reads from the event log and never writes to it. You can have as many projections as you like from the same events, each optimized for a different query. If you change what data the UI needs, you change the projection and replay; the event log is untouched.
Chronicle projections and reducers are rewindable: use Chronicle’s replay tooling to rebuild a view from its event history. That lets you change how authors are presented without changing the registration facts. Rebuilding depends on retaining the required history and keeping the projection compatible with it; it is not a substitute for event-store backups.
Folder structure
Section titled “Folder structure”Source/└── Authors/ ├── AuthorId.cs ← Shared concept (from the State Change slice) ├── AuthorName.cs ← Shared concept (from the State Change slice) └── Listing/ ├── Listing.cs ← Read model and query (C# layout) └── Listing.tsx ← Shared React listing pageThe backend equivalent can live in Listing.kt for Kotlin or Listing.ts for TypeScript. For Java, put each public type in its own file: Author.java, AuthorReducer.java, and AuthorRegistered.java (reuse the registration slice’s event file if it already exists). Keep the shared concepts and registration event alongside the slice in each backend’s own source layout.
Step 1 — The backend slice
Section titled “Step 1 — The backend slice”Define an Author view with an identity and the two names from registration, then expose a query over that view. Reuse the registration event and shared concepts. The JVM and TypeScript tabs show the event declaration for context; if it already lives in the registration slice, do not declare it twice.
using System.Reactive.Subjects;using Cratis.Arc.Queries.ModelBound;using Cratis.Chronicle.Projections.ModelBound;using MongoDB.Driver;
[ReadModel][FromEvent<AuthorRegistered>]public record Author(AuthorId Id, AuthorName FirstName, AuthorName LastName){ public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> collection) => collection.Observe();}import io.cratis.arc.artifacts.FromServices;import io.cratis.arc.authorization.AllowAnonymous;import io.cratis.chronicle.IEventStore;import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.java.ReadModelsJavaBridge;import io.cratis.chronicle.observation.Reducer;import java.util.List;import java.util.concurrent.Flow;
@EventTypepublic record AuthorRegistered(AuthorName firstName, AuthorName lastName) { }
@io.cratis.arc.artifacts.ReadModel@io.cratis.chronicle.readModels.ReadModel@AllowAnonymouspublic record Author(String id, AuthorName firstName, AuthorName lastName) { public static Flow.Publisher<List<Author>> allAuthors(@FromServices IEventStore store) { return ReadModelsJavaBridge.observeMaterializedInstancesPublisher(store.getReadModels(), Author.class, 0, 50); }}
@Reducerpublic final class AuthorReducer { public Author registered(AuthorRegistered event, Author state, EventContext context) { return new Author(context.getEventSourceId(), event.firstName(), event.lastName()); }}import io.cratis.arc.artifacts.FromServicesimport io.cratis.arc.artifacts.ReadModel as ArcReadModelimport io.cratis.arc.authorization.AllowAnonymousimport io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.Reducerimport io.cratis.chronicle.readModels.ReadModel as ChronicleReadModelimport kotlinx.coroutines.flow.Flow
@EventTypedata class AuthorRegistered(val firstName: AuthorName, val lastName: AuthorName)
@ArcReadModel@ChronicleReadModel@AllowAnonymousdata class Author( val id: String = "", val firstName: AuthorName = AuthorName(""), val lastName: AuthorName = AuthorName("")) { companion object { @JvmStatic fun allAuthors(@FromServices eventStore: IEventStore): Flow<List<Author>> = eventStore.readModels.materialized.observeInstances(Author::class, 0, 50) }}
@Reducerclass AuthorReducer { fun registered(event: AuthorRegistered, state: Author?, context: EventContext): Author = Author(context.eventSourceId, event.firstName, event.lastName)}import { field } from '@cratis/fundamentals';import { query, readModel, service } from '@cratis/arc.core';import { ChronicleReadModels } from '@cratis/arc.chronicle';import { eventType } from '@cratis/chronicle/events';import { fromEvent } from '@cratis/chronicle/projections';import type { Observable } from 'rxjs';
@eventType()export class AuthorRegistered { @field(AuthorName) firstName: AuthorName; @field(AuthorName) lastName: AuthorName; constructor(firstName = new AuthorName(''), lastName = new AuthorName('')) { this.firstName = firstName; this.lastName = lastName; }}
@readModel()@fromEvent(AuthorRegistered)export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) firstName!: AuthorName; @field(AuthorName) lastName!: AuthorName;
@query({ observable: true }, service(ChronicleReadModels)) static allAuthors(models: ChronicleReadModels): Observable<Author[]> { return models.observeAll(Author, author => author.id.toString()); }}What is happening here?
Section titled “What is happening here?”The read model describes the list the screen needs. In C# and TypeScript, a convention-based projection maps matching event properties to it, and the event-source ID supplies the model identity. The query observes changes to Chronicle’s MongoDB sink in C# and uses ChronicleReadModels.observeAll in TypeScript. Configure the host’s read-model storage and observation before expecting live updates.
Kotlin and Java use a reducer: their model-bound @FromEvent does not currently populate the model identity from the event-source ID, so the reducer reads EventContext to set id. On Kotlin and Java, allAuthors observes the first page of 50 materialized instances through Chronicle, which needs a Chronicle kernel of 19.22.2 or later. Do not substitute a Spring Data MongoDB observable query here: that observes a different persistence path.
Build the backend before importing the query in the UI. The build generates the query proxy AllAuthors and the model Author:
- C#:
dotnet build -c DebuggeneratesAllAuthors.tsandAuthor.ts(proxy generation). - Kotlin and Java:
./gradlew buildgeneratesAllAuthors.tsandAuthor.tsunderbuild/generated/arc-proxies/(TypeScript proxies). - TypeScript: proxy generation writes
AllAuthors.proxy.tsandAuthor.proxy.tsbeside the slice; the Library sample runs it as part ofyarn workspace @cratis/arc.sample.library build(generated code).
Match the frontend imports to the generated files in your application.
Step 2 — Projection mapping options
Section titled “Step 2 — Projection mapping options”Convention mapping works when event and read-model property names match. C# also offers attributes for explicit mapping and arithmetic:
| 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 |
Use a fluent projection when you want the mapping in a separate type. It is an alternative to the convention annotation on the read model: when you register a separate projection, remove that annotation so the view is not defined twice.
using Cratis.Chronicle.Projections;
public class AuthorProjection : IProjectionFor<Author>{ public void Define(IProjectionBuilderFor<Author> builder) => builder .From<AuthorRegistered>();}import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.projections.IProjectionBuilderFor;import io.cratis.chronicle.projections.IProjectionFor;
@EventTypepublic record AuthorImported(String id, AuthorName firstName, AuthorName lastName) { }
public final class AuthorProjection implements IProjectionFor<Author> { @Override public void define(IProjectionBuilderFor<Author> builder) { builder.from(AuthorImported.class); }}import io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.projections.IProjectionBuilderForimport io.cratis.chronicle.projections.IProjectionFor
@EventTypedata class AuthorImported(val id: String, val firstName: AuthorName, val lastName: AuthorName)
class AuthorProjection : IProjectionFor<Author> { override fun define(builder: IProjectionBuilderFor<Author>) { builder.from(AuthorImported::class) }}import { field } from '@cratis/fundamentals';import { readModel } from '@cratis/arc.core';import { eventType } from '@cratis/chronicle/events';import { projection, type IProjectionBuilderFor, type IProjectionFor } from '@cratis/chronicle';
@eventType()export class AuthorImported { @field(AuthorId) id!: AuthorId; @field(AuthorName) firstName!: AuthorName; @field(AuthorName) lastName!: AuthorName;}
@readModel()export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) firstName!: AuthorName; @field(AuthorName) lastName!: AuthorName;}
@projection('AuthorProjection', Author)export class AuthorProjection implements IProjectionFor<Author> { define(builder: IProjectionBuilderFor<Author>): void { builder.autoMap().from(AuthorImported); }}C# and Kotlin/Java enable AutoMap by default for From/from; TypeScript calls autoMap() explicitly. The JVM and TypeScript examples use an AuthorImported event that carries its own id. On the JVM this is an either/or choice for the Author view: use the Step 1 reducer for AuthorRegistered, which has no ID field, or use this fluent projection for an ID-bearing AuthorImported event. Do not register both for the same view; a fluent mapping of the registration event’s names alone cannot supply its identity. In Java, put the fluent example’s public types in AuthorImported.java and AuthorProjection.java (and reuse Author.java from the chosen view).
Step 3 — The React component
Section titled “Step 3 — The React component”import { useDialog } from '@cratis/arc.react/dialogs';import { Column } from '@cratis/components/DataTables';import { DataPage, MenuItem } from '@cratis/components/DataPage';import { Guid } from '@cratis/fundamentals';import { AllAuthors } from './AllAuthors';import { AddAuthor } from '../Registration/AddAuthor';
export const Listing = () => { const [AddAuthorDialog, showAddAuthor] = useDialog<Guid>(AddAuthor);
return ( <> <DataPage title="Authors" query={AllAuthors} emptyMessage="No authors registered yet" dataKey="id" > <DataPage.MenuItems> <MenuItem label="Add Author" command={async () => { await showAddAuthor(); // An observable backend pushes the updated list }} /> </DataPage.MenuItems>
<DataPage.Columns> <Column field="firstName" header="First Name" sortable /> <Column field="lastName" header="Last Name" sortable /> </DataPage.Columns> </DataPage>
<AddAuthorDialog /> </> );};What is happening here?
Section titled “What is happening here?”AllAuthors is the generated query proxy for the author list. With the observable C# or TypeScript query, DataPage subscribes to new lists and re-renders when the backend pushes a change.
DataPage from @cratis/components provides the page chrome: title, action menu bar, a data table with sorting and filtering, and pagination. Declare columns with Column inside DataPage.Columns, and actions inside DataPage.MenuItems. This example queries the whole author list; for a large catalog, add server-side paging, because table pagination does not limit the work the backend does.
useDialog<Guid>(AddAuthor) from @cratis/arc.react/dialogs supplies the wrapper component rendered in JSX and an async function that opens it. showAddAuthor resolves to [dialogResult, authorId] because AddAuthor closes with the Guid identity passed to its onSuccess callback. The observable query does not need that ID to update. With a TypeScript backend, AddAuthor closes with the ID’s string: use useDialog<string>(AddAuthor) and drop the then-unused Guid import.
MenuItem in the MenuItems slot adds an action to the toolbar. The command handler awaits the dialog; with an observable query, the list updates after a successful registration.
The AddAuthor component is imported from the Registration slice. Slices within the same feature can use each other’s components 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. This minimal route composition assumes the application’s Arc and Components providers are already mounted above App; if it already has a router, add only the route to its existing Routes.
import { BrowserRouter, Route, Routes } from 'react-router-dom';import { Authors } from './Authors/Authors';
export const App = () => ( <BrowserRouter> <Routes> <Route path="/authors" element={<Authors />} /> </Routes> </BrowserRouter>);