Skip to content

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.

In Event Modeling terms, a State View slice answers the question: “What does the user need to see right now?”

The shape is:

  1. Recorded events build a Read Model through a projection or reducer
  2. The read model is a purpose-built view in the exact shape one specific UI needs, instead of a generic table
  3. 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.

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 page

The 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.

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();
}

View C# snippet source on GitHub

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 Debug generates AllAuthors.ts and Author.ts (proxy generation).
  • Kotlin and Java: ./gradlew build generates AllAuthors.ts and Author.ts under build/generated/arc-proxies/ (TypeScript proxies).
  • TypeScript: proxy generation writes AllAuthors.proxy.ts and Author.proxy.ts beside the slice; the Library sample runs it as part of yarn workspace @cratis/arc.sample.library build (generated code).

Match the frontend imports to the generated files in your application.

Convention mapping works when event and read-model property names match. C# also offers attributes for explicit mapping and arithmetic:

AttributeWhat it does
[FromEvent<T>] on the recordAuto-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>();
}

View C# snippet source on GitHub

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).

Authors/Listing/Listing.tsx
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 />
</>
);
};

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.

Each feature has a composition page that assembles its slices.

Authors/Authors.tsx
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.

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.

App.tsx
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>
);