Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. a model shared across server code and client code or generated bindings;
  2. a transaction committed on one node;
  3. replication through a connector;
  4. storage on both durable and ephemeral nodes; and
  5. a live query driving a reactive UI.

Where to go next