DraftThis article is currently in draft mode

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 useState suffices

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" })), }; }