A Synchronized Feature, End to End
This guided trace connects the pieces you receive from an Ankurah template: a shared Rust model, a durable server node, an ephemeral client node, a live query, and a reactive component. The snippets are transcluded from this site’s Ankurah 0.9 example workspace so the Rust, WASM, and React builds validate the same code shown here.
This is an end-to-end tour of the site’s Album validation workspace, not a
literal patch against the templates’ Message model. For a runnable generated
application, complete Quick Start first; then use this tour to
see where the equivalent model, server, binding, query, and UI pieces fit.
If you have not generated a project yet, start with the
Quick Start. The template uses a chat model; this page uses a
smaller Album model so the data flow is easy to see.
1. Define the shared model
Models live in the Rust model crate shared by the server and client bindings.
#[derive(Model, Debug, Serialize, Deserialize)]
pub struct Album {
#[active_type(YrsString)]
pub name: String,
pub artist: String,
pub year: i32,
}
Deriving Model implements the model contract for Album and generates the
read-only AlbumView plus the transaction-bound AlbumMut handle. The
user-defined Album struct is also the create input. See
Defining Models for field types and
Choosing a Merge Strategy for how each field
resolves concurrent changes.
2. Start a durable node
The server owns a durable node and exposes it through the WebSocket connector:
let storage = SledStorageEngine::with_path(storage_dir)?;
let node = Node::new_durable(Arc::new(storage), PermissiveAgent::new());
node.system.wait_loaded().await;
if node.system.root().is_none() {
node.system.create().await?;
}
let mut server = WebsocketServer::new(node);
println!("Running server...");
server.run("127.0.0.1:9797").await?;
wait_loaded() is the readiness barrier for checking the local system catalog
before the code reads its root. The root is created only when the store has none, so
restarting the process reopens the same system instead of trying to create a
second one. PermissiveAgent is appropriate for local development only; use
Authentication & Policy before exposing a server to
untrusted clients. For the current readiness-wait edge case, see
Deployment & Operations.
3. Create data in a transaction
Application model writes are transactional. Create the model, retain its generated id if you need it, and commit once:
let trx = ctx.begin();
let album = trx.create(&Album {
name: "Parade".into(),
artist: "Prince".into(),
year: 1986,
}).await?;
let album_id = album.id();
trx.commit().await?;
The commit creates an immutable event, updates the local materialized state, and lets the node’s reactor update matching live queries. Connected durable peers receive the event; subscribed ephemeral peers receive matching changes through their live-query subscriptions.
4. Read once or stay subscribed
Use fetch() for a one-time snapshot:
// Fetch with a string query - one-time snapshot
let albums: Vec<AlbumView> = ctx.fetch("year > 1985").await?;
Use query() when the result set should continue updating:
// Using selection! macro with ctx.query()
let q: LiveQuery<AlbumView> = ctx.query(selection!("year > 1985"))?;
An entity can enter, change within, or leave a live query’s result set as commits apply. Querying Data explains that lifecycle; AnkQL Syntax covers predicates, ordering, limits, and safe value substitution.
5. Connect a browser node
The browser uses IndexedDB for its local working set and connects to a durable peer over WebSockets:
let storage = IndexedDBStorageEngine::open("myapp").await?;
let node = Node::new(Arc::new(storage), PermissiveAgent::new());
let client = WebsocketClient::new(node.clone(), server_url)?;
node.system.wait_system_ready().await;
let context = node.context(DEFAULT_CONTEXT)?;
CONTEXT.with(|slot| slot.replace(Some(context)));
CLIENT.with(|slot| slot.replace(Some(client)));
wait_system_ready() is the corresponding client-side readiness barrier for
learning which system it joined.
Keep the returned connector handle alive for as long as the node should remain
connected.
Client initialization is asynchronous. Do not call ctx() or create queries
during the first React render; wait for initialize_client() to resolve, then
render the component that owns them. This hook makes that readiness state
explicit:
let clientInitialization: Promise<void> | undefined;
function initializeClientOnce(): Promise<void> {
clientInitialization ??= initialize_client("ws://localhost:9797");
return clientInitialization;
}
function useClientReady() {
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
initializeClientOnce()
.then(() => {
if (!cancelled) setReady(true);
})
.catch((err) => {
if (!cancelled) setError(String(err));
});
return () => {
cancelled = true;
};
}, []);
return { ready, error };
}
6. Observe the live query in React
The React templates create signalObserver with @ankurah/react-hooks; this
small example defines the same wrapper directly from the generated useObserve
binding. Create the live query only inside the subtree rendered after
initialization:
export function signalObserver<T>(fc: React.FC<T>): React.FC<T> {
return (props: T) => {
const observer = useObserve();
try {
return fc(props);
} finally {
observer.finish();
}
};
}
let albumsQuery: AlbumLiveQuery | undefined;
function queryAlbums(): AlbumLiveQuery {
albumsQuery ??= Album.query(ctx(), "year > 1985");
return albumsQuery;
}
The factory caches the query for this app’s singleton client. That also makes
the example safe when React development StrictMode probes a component more
than once.
The ready-only component constructs that query once and passes it to the observed list:
const ReadyAlbums = signalObserver(() => {
const albums = useMemo(queryAlbums, []);
return (
<div>
<h2>{"Albums (year > 1985)"}</h2>
<AlbumList albums={albums} />
</div>
);
});
Gate that subtree on the readiness state returned above:
const content = ready ? (
<ReadyAlbums />
) : (
<div className="status">
Connecting to server at ws://localhost:9797...
</div>
);
ReadyAlbums passes the query into an observed list component. Reading
albums.items registers that render as a dependent of the live query:
interface Props {
albums: AlbumLiveQuery;
}
/* Bind a React observer to the component. */
const AlbumList = signalObserver(({ albums }: Props) => {
return (
<ul>
{/* Reading items registers this render as a live-query observer. */}
{albums.items.map((album) => (
<li key={album.id.to_base64()}>{album.name}</li>
))}
</ul>
);
});
The component rerenders when the query’s result set changes; application code does not need to maintain a second subscription or copy the rows into React state. See React Bindings for initialization and hook setup.
7. Write from React
The generated model namespace accepts the same transaction pattern from TypeScript. Create a row, then commit:
export async function createAlbum(
name: string,
artist: string,
year: number,
): Promise<AlbumView> {
const transaction = ctx().begin();
const album = await Album.create(transaction, { name, artist, year });
await transaction.commit();
return album;
}
Views stay read-only. To update one, obtain its transaction-bound mutable handle and use the field backend’s mutation method before committing:
export async function renameAlbum(
album: AlbumView,
name: string,
): Promise<void> {
const transaction = ctx().begin();
album.edit(transaction).name.replace(name);
await transaction.commit();
}
name is Yrs-backed text, so its wrapper exposes replace. An LWW field such
as year exposes set instead.
8. Verify synchronization
The generated templates use Message rather than Album, but exercise the
same sequence: a shared model, create/edit inside a transaction, a live
query, and an observed component.
Run a generated React or Leptos project with ./dev.sh, then open the printed
web URL in one regular browser window and one private/incognito window. The two
windows use independent IndexedDB stores, so they behave as separate client
nodes. Create or edit data in one and confirm that the other updates without a
refresh. For an existing React Native checkout or previously generated project,
start the Rust server as shown in the template and use a separately configured
second simulator or device for the second client; the current generation
blocker is noted in Quick Start, and dev.sh launches one fixed
simulator target.
At that point you have exercised the complete path:
- a model shared across server code and client code or generated bindings;
- a transaction committed on one node;
- replication through a connector;
- storage on both durable and ephemeral nodes; and
- a live query driving a reactive UI.
Where to go next
- Defining Models – add fields, references, and updates.
- Querying Data – choose between snapshots and live results.
- Authentication & Policy – replace the development policy agent.
- Deployment & Operations – choose durable storage and prepare a server.