HomeGuidesView state

View state

How to keep per-view UI state on the host with injectViewState and injectViewStore.

The host can remove a view and then render it again. The user scrolls the conversation away and back, or opens a widget later. UI state in a plain component field does not survive this.

The host keeps a small store for each view. ng-mcp-ui gives you two ways to use it.

View state is client-owned UI state: a selection, a draft, a scroll position. The host holds it, and the client can change it, therefore a server cannot trust what comes back. For server state that a widget carries between tool calls, use sealed state. The two work together: seal the state on the server, then keep the token in view state so a remounted widget still has it.

injectViewState for one value

For one piece of state, injectViewState is enough. It gives you { value, set }. The value member is a signal. The set member accepts the next value, or a function of the previous value.

import { injectViewState } from "ng-mcp-ui/web";

const viewState = injectViewState<{ myVote: string | null }>({ myVote: null });

viewState.value();                                   // Signal<{ myVote: string | null } | null>
viewState.set({ myVote: "Ramen" });                  // replace
viewState.set((prev) => ({ myVote: prev?.myVote ?? null })); // update

value() is null until the host reports state.

The sync goes in two directions. A change on the host flows back into the signal.

injectViewStore for more than one value

injectViewStore is the store-style API over the same host sync. Use it for a view that has outgrown one value and one setter.

import { injectViewStore } from "ng-mcp-ui/web";

const store = injectViewStore({ filter: "", expanded: [] as string[] });

store.state();                       // the whole state as a signal
store.set({ filter: "", expanded: [] });
store.update((prev) => ({ ...prev, filter: "ramen" }));
store.patch({ filter: "ramen" });    // shallow merge
const filter = store.select((s) => s?.filter ?? ""); // memoized selector signal
store.flush();                       // write the pending value now

The store adds three things to the simple form.

  • Debounced host writes. A user who types quickly does not send one message for each keystroke.
  • A conflict guard. The store compares an incoming value with the value it holds, therefore the echo of your own write does not cause a second write.
  • View-context filtering, with a re-attach.

flush() returns nothing, and it writes immediately. Call it before an action that ends the view, because a debounced write can otherwise be lost.

store.flush();
await requestClose();

View context

Both APIs write under a reserved key of the host viewState, next to the [dataLlm] channel. The helpers are exported for an advanced caller.

Symbol Purpose
VIEW_CONTEXT_KEY The reserved key.
injectViewContext() The raw context signal.
filterViewContext(...) Removes the context envelope from a raw host value.

Most widgets never need these. Use them only when you read or write the host view state directly.

Host support

Both APIs work on the two host runtimes. The storage differs. An Apps SDK host keeps the state in its widget state. An MCP Apps host sends the state to the host, and it also writes a copy to localStorage. Therefore an MCP Apps view can show its last state immediately after a reload.