View Model Interface Guidelines
October 17, 2025
View Model Interface Guidelines#
Principles (how to shape it)#
- Name the UI regions explicitly in the interface
- Prefer fine-grained reactive primitives (no global invalidation)
- Accept domain inputs; return UI-ready outputs
- Keep event handlers side-effectful but opaque to the view
When it shines / When not to use#
- Shines: rich inputs, keyboard nav, selections, async suggestions, controlled animations
- Shines: teams splitting view/business logic; AI-assisted UI implementation
- Shines: complex animations that need precise triggering from business logic
- Not for: trivial forms, static pages, or where a single
useStatesuffices
Testing Workflow (evidence over rhetoric)#
- Drive behavior by calling VM methods and asserting reactive outputs
- UI tests become thin: render is a projection over VM
- Animation triggers are testable: increment atom, assert on UI state changes
Introduction#
View Model Interfaces are a pattern for designing the boundary between view and business logic.
Let's take a look at a simple example of what a view model interface for the following autocomplete input might look like.
Interface
export interface AutocompleteVM {
/** The actual user's input text area */
inputText$: Queryable<string>;
updateInput(text: string, pos: "end" | number): void;
/** Selection affects the autocomplete options */
updateSelection(pos: number, anchor?: number): void;
/** Keyboard navigation affects what is selected */
pressKey(key: "enter" | "up" | "down" | "escape"): void;
/** Options shown below the editor input when non-empty */
options$: Queryable<
Array<{
key: string;
label: string;
selected$: Queryable<boolean>;
click(): void;
}>
>;
}Test
File Reference Autocomplete
Type @ followed by a filename to see autocomplete suggestions. Use arrow keys to navigate, Enter to select, or click an option.
autocomplete.updateInput("Update @ind", "end");
const options = query(autocomplete.options$);
expect(options).toMatchObject([
{ label: "src/index.html" },
{ label: "src/index.ts" },
{ label: "src/utils/index.ts" },
]);
// Default is that the first option is selected
expect(query(options[0].selected$)).toBe(true);
expect(query(options[1].selected$)).toBe(false);
autocomplete.pressKey("down");
expect(query(options[0].selected$)).toBe(false);
expect(query(options[1].selected$)).toBe(true);Benefits#
- Unit-testable without DOM: assert on VM streams and actions
- Predictable updates: fine-grained reactivity, no accidental re-renders
- Simpler React: render from data, dot-map, minimal effects
Example#
Interface
export type TodoItemVM = {
key: string;
text$: Queryable<string>;
completed$: Queryable<boolean>;
toggleCompleted: () => void;
remove: () => void;
};
export type TodoListVM = {
header: {
newTodoText$: Queryable<string>;
updateNewTodoText: (text: string) => void;
addTodo: () => void;
};
itemList: {
items$: Queryable<TodoItemVM[]>;
};
footer: {
incompleteDisplayText$: Queryable<string>;
currentFilter$: Queryable<"all" | "active" | "completed">;
showAll: () => void;
showActive: () => void;
showCompleted: () => void;
clearCompleted: () => void;
};
reset: () => void;
};React Code
Todo List View
export const MainSection: React.FC = () => {
const vm = useTodoVM();
const sectionRef = useRef<HTMLElement>(null);
return (
<section ref={sectionRef} className="todos">
<ul className="todo-list">
<Queryable query={vm.itemList.items$}>
{(items) =>
items.map((item) => (
<li className="state" key={item.key}>
<Queryable query={item.completed$}>
{(completed) => (
<input className="toggle" type="checkbox" checked={completed} onChange={() => item.toggleCompleted()} />
)}
</Queryable>
<label >
<Queryable query={item.text$} />
</label>
<button type="button" className="destroy" onClick={() => item.remove()} />
</li>
))
}
</Queryable>
</ul>
</section>
);
};Animation Triggers#
View Models can expose animation triggers using PrimitiveAtom<number> for precise control over visual feedback:
interface ProposalVM {
// ... other properties
// Animation triggers - writable atoms that increment to trigger effects
animationTriggers: {
commitAddedAtom: PrimitiveAtom<number>; // Flash when commit added
mergedAtom: PrimitiveAtom<number>; // Pulse when merged
};
}Why this pattern?
- Declarative: Reading the interface reveals animation capabilities
- Testable: Writable atoms allow resetting and manual triggering in tests
- Framework-agnostic: Works with any reactive renderer
- Self-describing: Clear semantic meaning in the interface
Component consumption:
const ProposalComponent = ({ proposal }: { proposal: ProposalVM }) => {
const trigger = useAtomValue(proposal.animationTriggers.commitAddedAtom);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (trigger > 0) {
setIsAnimating(true);
const timer = setTimeout(() => setIsAnimating(false), 500);
return () => clearTimeout(timer);
}
}, [trigger]);
// Use OKLCH for dynamic colors
const color = proposal.backgroundColor;
const animStyle = isAnimating ? {
boxShadow: `0 0 0 2px oklch(0.75 ${color.chroma} ${color.hue})`,
backgroundColor: `oklch(0.95 ${color.chroma * 0.3} ${color.hue})`,
} : {};
return <div style={animStyle} className="transition-all duration-500">
{/* content */}
</div>;
};Business logic side:
const dispatchCommit = (proposalId: ProposalID) => {
// ... commit logic
// Trigger animation
const atoms = memoProposalAnimationAtoms(proposalId);
jotaiStore.set(atoms.commitAddedAtom, (prev) => prev + 1);
};Testing:
// Reset animation state
jotaiStore.set(proposal.animationTriggers.commitAddedAtom, 0);
// Manually trigger for testing
jotaiStore.set(proposal.animationTriggers.commitAddedAtom, (n) => n + 1);This pattern ensures animations are triggered from business logic events while keeping the UI a pure projection of state.
Defining a Scope#
const createID = (name: string) => `${name}_${nanoid(12)}`;
export function createTodoListScope(store: Store<typeof schema>): TodoListVM {
const currentFilter$ = computed((get) => get(uiState$).filter, { label: "filter" });
const newTodoText$ = computed((get) => get(uiState$).newTodoText, { label: "newTodoText" });
const createTodoItemVM = memoFn((id: string): TodoItemVM => {
const completed$ = queryDb(
tables.todos.select("completed").where({ id }).first({ behaviour: "error" }),
{ label: "todoItem.completed", deps: [id] }, );
const text$ = queryDb(tables.todos.select("text").where({ id }).first({ behaviour: "error" }), {
label: "todoItem.text",
deps: [id], });
return {
key: id,
completed$,
text$,
toggleCompleted: () => {
emitFunctionCall("todo-toggleCompleted", { args: [] });
store.commit(store.query(completed$) ? events.todoUncompleted({ id }) : events.todoCompleted({ id }));
},
remove: () => {
emitFunctionCall("todo-remove", { args: [] });
store.commit(events.todoDeleted({ id, deletedAt: new Date() }));
},
};
});
const visibleTodosQuery = (filter: "all" | "active" | "completed") =>
queryDb(
() =>
tables.todos.where({
completed: filter === "all" ? undefined : filter === "completed",
deletedAt: { op: "=", value: null },
}),
{
label: "visibleTodos",
map: (rows) => rows.map((row) => createTodoItemVM(row.id)),
deps: [filter],
},
);
const visibleTodos$ = computed(
(get) => {
const filter = get(currentFilter$);
return get(visibleTodosQuery(filter));
},
{ label: "visibleTodos" }, );
const incompleteCount_$ = queryDb(tables.todos.count().where({ completed: false, deletedAt: null }), {
label: "incompleteDisplayText",
});
const incompleteDisplayText$ = computed(
(get) => `${get(incompleteCount_$)} ${pluralize(get(incompleteCount_$), "item", "items")} left`,
);
return {
header: {
newTodoText$,
updateNewTodoText: (text: string) => {
emitFunctionCall("todo-updateNewTodoText", { args: [text] });
store.commit(events.uiStateSet({ newTodoText: text }));
},
addTodo: () => {
emitFunctionCall("todo-addTodo", { args: [] });
const newTodoText = store.query(newTodoText$).trim();
if (newTodoText) {
store.commit(
events.todoCreated({ id: createID("todo"), text: newTodoText }),
events.uiStateSet({ newTodoText: "" }), // update text
);
}
},
},
itemList: {
items$: visibleTodos$,
},
footer: {
incompleteDisplayText$,
currentFilter$,
showAll: () => {
emitFunctionCall("todo-showAll", { args: [] });
store.commit(events.uiStateSet({ filter: "all" }));
},
showActive: () => {
emitFunctionCall("todo-showActive", { args: [] });
store.commit(events.uiStateSet({ filter: "active" }));
},
showCompleted: () => {
emitFunctionCall("todo-showCompleted", { args: [] });
store.commit(events.uiStateSet({ filter: "completed" }));
},
clearCompleted: () => {
emitFunctionCall("todo-clearCompleted", { args: [] });
store.commit(events.todoClearedCompleted({ deletedAt: new Date() }));
},
},
reset: () => store.commit(events.stateReset({}), events.uiStateSet({ newTodoText: "", filter: "all" })),
};
}