DraftThis article is currently in draft mode

Writing view model interfaces

Adaptations
TXT
Cole Lawrence
October 17, 2025
Writing view model interfaces

Intro
#

I could never make sense of React hooks once useEffects, useStates, and contexts piled up. I'd squint at the code and kind of follow a few pieces, but I couldn't honestly say I understood what was happening. Event handling, styling, render performance, testing—it all blurred together.

Now my AI agents have the same problem.

The pattern that solved this for me—and now keeps my agents from accruing subtle technical debt—is the view model interface. It's the slice of model-view-view-model that matters: a clear separation between business logic (what your app does uniquely) and view concerns (events, animation, web/native-specific challenges). I don't care how you implement your business logic or state management. All that matters is the separation, so AI can work out implementations without tangling domain logic with UI noise.

Try it

File Reference Autocomplete

Type @ followed by a filename to see autocomplete suggestions. Use arrow keys to navigate, Enter to select, or click an option.

Interface

export interface AutocompleteVM {
  /** The actual user's input text area */
  inputText$: ReadonlySignal<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$: ReadonlySignal<
    Array<{
      key: string;
      label: string;
      selected$: ReadonlySignal<boolean>;
      click(): void; 
    }>
    
  >;
}

What is a View Model Interface?
#

By strictly following a few rules for view model interfaces, we define a clean boundary between the User Interface (UI) and the actual business logic. This offers two significant advantages:

  1. Reduced complexity: our business logic and UI are isolated from each other, making both easier to reason about.
  2. Testing: our tests can test what matters without rendering the UI, making our tests faster, more focused, and easier to maintain.

ReadonlySignal<T> is a read-only reactive primitive—think of it like an RxJS Observable, a Jotai read-only atom, a Preact ReadonlySignal, or a Solid signal getter. You can subscribe to it and read its current value, but you can't write to it directly. The $ suffix is a naming convention to signal "this value may change over time."

As you can see, it isn't magic. It should seem very familiar, and only a couple of deliberate choices give us incredible power in the design and implementation phases that we orchestrate with our agents.

The Four Rules
#

  1. Let the view model structure mirror the UI
  2. Use fine-grained reactive primitives (no global invalidation)
  3. Accept domain inputs; return UI-ready outputs
  4. Keep event handlers side-effectful but opaque to the view

1. Let the view model structure mirror the UI
#

Shape your View Model hierarchy to directly mirror what appears in the UI.

Bad
historyManagement: {
  draftChanges,
  timelineScrubber,
}
Good
topBar: { historyTimelineScrubber },
sidePanel: { draftChangeArea }

Tip: Include doc-comments to clarify nuanced behavior.

2. Use fine-grained reactive primitives
#

Avoid global invalidation or full re-renders. Reading our view model interface should tell other engineers exactly what parts of the application are dynamic.

Reactive fields usually end with a special suffix like *$ or *Atom depending on the framework (e.g., isSelected$) to signal they may change during the VM instance's lifetime. Static fields (no suffix) are appropriate when the value is fixed for as long as that VM object exists; if the underlying data changes structurally, create new VM instances rather than mutating existing ones.

Bad
type ListVM = {
  items: ItemVM[];
  // ...
};
type ItemVM = {
  itemSelected: boolean;
  // ...
};
Good
type ListVM = {
  items$: ReadonlySignal<ItemVM[]>;
  // ...
};
type ItemVM = {
  itemSelected$: ReadonlySignal<boolean>;
  // ...
};

3. Accept domain inputs; return UI-ready outputs
#

Present Data as UI-Ready Strings
#

Every piece of data shown to the user should already be localized and formatted before it reaches the UI. For example, don't expose raw types like Date or number directly; pre-format for display.

Bad
datePosted: Date;
Good
postedAtDisplay: string; // "Thu, Nov 20th, 2025"
Bad
amount: number;
currency: string;
currencySide: "left" | "right";
Good
amountDisplay: string; // "$1,000.00" or "ÂŁ1,000.00" or "1.000 BRL"

// For more complex UIs:
amountDisplay: {
  pre: string; // "$" or "ÂŁ" or ""
  number: Array<{ text: string; muted: boolean }>; // [{ text: "1", muted: false }, { text: ",", muted: true }, { text: "000", muted: false }, { text: ".00", muted: true }]
  post: string; // "USD" or "GBP" or "BRL"
}

Never Expose Raw IDs to the UI
#

Exposing IDs can lead the UI to leak business logic and is risky with AI assuming it can use IDs for business logic in the UI.

Bad
type ListVM = {
  onClickItem(id: string): void;
  items: { id: string }[];
};
Good
type ListVM = {
  items: Array<{
    key: string;
    onClick: () => void;
  }>;
};

Pattern: Domain Model vs. View Model
#

When mapping a domain object to a view model, transform the ID into a key and keep the ID hidden within the closure.

// Domain model
type User = { id: string; name: string };

// View model
type UserRowVM = {
  key: string; // usually the User.id (or a composite)
  label: string; // user-facing name
  onClick: () => void; // callback closes over User.id internally
};

4. Keep event handlers side-effectful but opaque to the view
#

This further limits what the UI can access, preventing the implementer from relying on patterns (like returning promises) that could lead to UI logic handling async side-effects, which should remain hidden behind the view model boundary.

Bad
onClick: () => Promise<void>;
Good
onClick: () => void;

Composition
#

A View Model Interface isn't meant to be a single monolithic object. Just as we compose UI components—a Page containing a Sidebar and a ContentArea—we compose View Models.

This is achieved by nesting interfaces. A "parent" View Model simply exposes "child" View Models as properties.

interface PageVM {
  sidebar: SidebarVM;
  content: ContentVM;
}

On the UI side, you mirror this composition by wiring up the VM tree directly to your component tree:

function Page({ vm }: { vm: PageVM }) {
  return (
    <div className="layout">
      <Sidebar vm={vm.sidebar} />
      <Content vm={vm.content} />
    </div>
  );
}

This compositional approach enables what I call fractal architecture—every view model is safely encapsulated, and yet can be composed into arbitrarily complex applications. Here are the benefits:

  1. Isolation & Testability: Components consume only the view model slice they need, e.g. Sidebar uses just a SidebarVM, so tests and changes are tightly scoped.
  2. Scalability: By assembling applications from modular view models, complexity scales linearly instead of exponentially.
  3. Parallel Work: Different teams or AI agents can implement and evolve isolated view model sections (Sidebar, Content) after a shared interface contract is established.

In practice, this architecture automates separation of concerns and encourages durable, easy-to-refactor code.


Practices & Edge Cases
#

Using inline reactivity can be nice
#

Don't arbitrarily split up components—often, it's clearer to use an inline reactive renderer:

<ReadonlySignal query={vm.todos$}>
  {todos => /* this is a valid hook context */}
</ReadonlySignal>

<ReadonlySignal query={vm.header.newTodoText$}>
  {(newTodoText) => (
    <input
      className="new-todo"
      placeholder="What needs to be done?"
      value={newTodoText}
      onChange={(e) => vm.header.updateNewTodoText(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === "Enter") {
          vm.header.addTodo();
        }
      }}
    />
  )}
</ReadonlySignal>

<span className="todo-count">
  <vm.footer.incompleteDisplayText$ />
</span>

This approach also suits LLMs, which typically make focused edits within single blocks rather than scattered edits.

React components on the view model are OK (with a caveat)
#

You can attach presentational React components (like icons or renderer functions) directly to your view model, so long as:

  • The business logic remains fully testable.
  • Those components are strictly for rendering/decoration.
icon: typeof Ta.Icon123;
renderDiff: (props) => React.ReactNode;

Prefer booleans over discriminated unions where possible
#

Flatten unions for simple UI controls (buttons, dropdowns, etc.) to reduce UI complexity and make event handling, rendering, and testing more straightforward. Try to minimize conditional logic and indirection.

Bad
type ButtonVM = {
  enabled$: ReadonlySignal<
    | { enabled: true; click: () => void } //
    | { enabled: false; disableDisplayReason: string }
  >;
};
Good
type ButtonVM = {
  disabled$: ReadonlySignal<null | { displayReason: string }>;
  // click is always available, even if root-level event wiring is needed
  click: () => void;
};
Bad
type DropdownVM = {
  state$: ReadonlySignal<
    | {
        state: "open";
        keyDown: (key: "up" | "down") => void;
      }
    | {
        state: "closed";
      }
  >;
};
Good
type DropdownVM = {
  options$: ReadonlySignal<DropdownItemVM[]>;
  keyDown: (key: "up" | "down") => boolean;
};

Keyboard/event handlers may return booleans
#

Allow your keyboard or other event handlers to return a boolean indicating whether they've handled the event (to control propagation/default handling):

onKeyDown(event): boolean

Add an opaque _dev field for debugging
#

Add a field like _dev: Record<string, unknown> to your model for UI-side debugging tools.
Just ensure its structure remains opaque.

Animation triggers
#

View Models can expose animation triggers using Signal<number> for precise control over visual feedback:

interface ProposalVM {
  // ... other properties

  // Animation triggers - writable signals that increment to trigger effects
  animationTriggers: {
    commitAdded$: Signal<number>; // Flash when commit added
    merged$: Signal<number>; // Pulse when merged
  };
}

Signal<T> is a writable reactive primitive—like an RxJS BehaviorSubject, a Jotai writable atom, a Preact Signal, or a Solid createSignal. Unlike ReadonlySignal, you can both read and write to it. We use writable signals sparingly in view model interfaces, typically for cases like animation triggers where the UI or test harness needs to reset or manually trigger state.

Why this pattern?

  • Declarative: Reading the interface reveals animation capabilities
  • Testable: Writables allow resetting and manual triggering in tests
  • Framework-agnostic: Works with any reactive renderer
  • Self-describing: Clear semantic meaning in the interface

Component consumption:

function ProposalComponent({ proposal }: { proposal: ProposalVM }) {
  const trigger = useSignalValue(proposal.animationTriggers.commitAdded$);
  const [isAnimating, setIsAnimating] = useState(false);

  useEffect(() => {
    if (trigger > 0) {
      setIsAnimating(true);
      const handle = setTimeout(() => setIsAnimating(false), 500);
      return () => clearTimeout(handle);
    }
  }, [trigger]);

  const { chroma, hue } = proposal.backgroundColor;
  const style = isAnimating
    ? {
        boxShadow: `0 0 0 2px oklch(0.75 ${chroma} ${hue})`,
        backgroundColor: `oklch(0.95 ${chroma * 0.3} ${hue})`,
      }
    : undefined;

  return (
    <div style={style} className="transition-all duration-500">
      {/* proposal content */}
    </div>
  );
}

Testing:

// Reset animation state
signalStore.setSignal(proposal.animationTriggers.commitAdded$, 0);

// Manually trigger for testing
proposal.animationTriggers.commitAdded$.value += 1

This pattern ensures animations are triggered from business logic events while keeping the UI a pure projection of state.


Examples
#

Example 1: Autocomplete
#

View / 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 = autocomplete.options$.value;
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(options[0].selected$.value).toBe(true);
expect(options[1].selected$.value).toBe(false);

autocomplete.pressKey("down");
expect(options[0].selected$.value).toBe(false);
expect(options[1].selected$.value).toBe(true);

Interface

export interface AutocompleteVM {
  /** The actual user's input text area */
  inputText$: ReadonlySignal<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$: ReadonlySignal<
    Array<{
      key: string;
      label: string;
      selected$: ReadonlySignal<boolean>;
      click(): void; 
    }>
    
  >;
}

Example 2: Todo List
#

Interface

export type TodoItemVM = {
  key: string;
  text$: ReadonlySignal<string>; 
  completed$: ReadonlySignal<boolean>; 
  toggleCompleted: () => void; 
  remove: () => void; 
};

export type TodoListVM = {
  header: {
    newTodoText$: ReadonlySignal<string>; 
    updateNewTodoText: (text: string) => void; 
    addTodo: () => void; 
  };
  itemList: {
    items$: ReadonlySignal<TodoItemVM[]>; 
  };
  footer: {
    incompleteDisplayText$: ReadonlySignal<string>; 
    currentFilter$: ReadonlySignal<"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">
        <ReadonlySignal query={vm.itemList.items$}>
          {(items) =>
            items.map((item) => (
              <li className="state" key={item.key}>
                <ReadonlySignal query={item.completed$}>
                  {(completed) => (
                    <input className="toggle" type="checkbox" checked={completed} onChange={() => item.toggleCompleted()} />
                  )}
                </ReadonlySignal>
                <label >
                  <item.text$ />
                </label>
                <button type="button" className="destroy" onClick={() => item.remove()} />
              </li>
            ))
          }
        </ReadonlySignal>
      </ul>
    </section>
  );
};

Example 3: Nested Composition
#

Real applications have deeply nested UIs. A typical sidebar might contain navigation, a user profile area, usage limits, dropdown menus—each with their own state and interactions. How do you keep this manageable?

The answer is fractal composition: each piece of UI gets its own view model, and parent view models simply expose child view models as properties. The sidebar doesn't need to know how the user profile section works—it just passes vm.userProfileSection to the component.

SidebarVM#
interface SidebarVM {
  navigation: NavigationVM;
  /** Sits below the main navigation */
  userProfileSection: SidebarUserProfileSectionVM;
}

The profile section contains multiple concerns—article limits, logged-in state, logged-out state—each scoped to what that part of the UI actually needs:

SidebarUserProfileSectionVM#
interface SidebarUserProfileSectionVM {
  /** Usage limits shown as a progress bar or text */
  articleLimit: SidebarArticleLimitVM;
  /** Shown when user is authenticated */
  loggedInUser$: ReadonlySignal<SidebarLoggedInUserVM | null>;
  /** Shown when user is not authenticated */
  loggedOut$: ReadonlySignal<SidebarLoggedOutVM | null>;
}
SidebarArticleLimitVM#
interface SidebarArticleLimitVM {
  hasUnlimitedArticles: boolean;
  freeArticlesRemaining$: ReadonlySignal<number>;
  currentArticleContributesToLimit$: ReadonlySignal<boolean>;
}
SidebarLoggedInUserVM#
interface SidebarLoggedInUserVM {
  displayName: string;
  avatarUrl: string;
  role: "guest" | "member" | "admin";
  dropdownMenu: SidebarUserDropdownMenuVM;
}

interface SidebarUserDropdownMenuVM {
  /** Options may change based on role, feature flags, etc. */
  options$: ReadonlySignal<
    Array<{
      key: string;
      label: string;
      icon: Ta.Icon;
      onClick: () => void; // closes over IDs—UI never sees them
    }>
  >;
}
SidebarLoggedOutVM#
interface SidebarLoggedOutVM {
  loginButton: {
    onClick: () => void;
  };
  // Could expand with SSO options, sign-up link, etc.
}

Notice how loggedInUser$ and loggedOut$ are reactive and mutually exclusive—only one is non-null at a time. The UI can simply check which one to render without complex conditional logic.

But what about the edit modal?
#

When the user clicks "Edit Profile" from the dropdown, a modal opens. This modal also deals with "user profile" data—but it's a completely different UI with different concerns: form fields, validation, saving state.

Should we reuse SidebarUserProfileSectionVM? No. That would couple two unrelated UI surfaces together.

Instead, we create a separate view model named after where it lives in the UI:

UserProfileEditModalVM#
interface UserProfileEditModalVM {
  /** Modal visibility and close action */
  isOpen$: ReadonlySignal<boolean>;
  close: () => void;

  /** Form fields */
  displayName: {
    value$: ReadonlySignal<string>;
    update: (value: string) => void;
    error$: ReadonlySignal<string | null>;
  };
  avatarUrl: {
    value$: ReadonlySignal<string>;
    update: (value: string) => void;
    error$: ReadonlySignal<string | null>;
  };

  /** Form actions */
  save: () => void;
  saving$: ReadonlySignal<boolean>;
  canSave$: ReadonlySignal<boolean>;
}

Both VMs deal with the same domain concept, but they serve completely different UI purposes. The sidebar shows a read-only summary; the modal handles editing with validation and error handling.

The naming convention: View model types are named after their UI location, not their domain concept. Your app might have:

  • SidebarUserProfileSectionVM — the compact profile card in the sidebar
  • UserProfilePageVM — the full profile page at /settings/profile
  • UserProfileEditModalVM — the modal that opens when you click "Edit"

This prevents naming collisions and makes it immediately clear where each VM is used. When you need to change how the dropdown works, you only touch SidebarUserDropdownMenuVM. When you need to add validation to the edit modal, you only touch UserProfileEditModalVM. The sidebar doesn't care—it just passes view models down.