platform-docs/architecture/mvvm-pattern.md

10 KiB

MVVM Pattern for React Features

Status: Active Applies to: All frontend features with form/state management First Implementation: marketplace/frontend-public/client-profile


Overview

We use a React-adapted MVVM (Model-View-ViewModel) pattern to separate business logic from presentation. This improves testability, maintainability, and enables independent evolution of UI and logic.

Layer Definitions

Layer Purpose Location Contains
Model Data structures, domain logic <feature>/model/ Types, constants, Zod schemas, transformers
ViewModel Presentation logic, state composition <feature>/viewmodels/ Custom hooks returning data + callbacks
View Pure presentation <feature>/views/ JSX components receiving all data via props
Container Glue layer <feature>/components/ Connects ViewModel to View

Folder Structure

<feature>/
├── api/                     # API clients (existing)
├── model/
│   ├── constants.ts         # Domain constants
│   ├── types.ts             # TypeScript types
│   ├── schemas.ts           # Zod validation (optional)
│   └── index.ts             # Exports
├── viewmodels/
│   ├── use<Entity>ViewModel.ts
│   ├── use<Entity>ViewModel.spec.tsx  # Unit tests (browser mode)
│   └── index.ts
├── views/
│   ├── <Component>View.tsx  # Pure presentation
│   └── index.ts
├── components/              # Containers (existing)
│   └── <Component>.tsx      # Thin glue layer
├── hooks/                   # Data fetching hooks (existing)
└── pages/                   # Route pages (existing)

Naming Conventions

Type Pattern Example
ViewModel use<Entity>ViewModel.ts useClientProfileFormViewModel.ts
View <Component>View.tsx ClientProfileFormView.tsx
Container <Component>.tsx ClientProfileForm.tsx

Layer Rules

Model Layer

The model contains domain types, constants, and validation schemas.

// model/constants.ts
export const AGE_RANGES = ['18-24', '25-34', '35-44', '45-54', '55+'] as const;
export type AgeRange = (typeof AGE_RANGES)[number];

export const FIELD_LIMITS = {
  displayName: { max: 50 },
  bio: { max: 500 },
} as const;
// model/types.ts
export interface ClientProfileFormFields {
  displayName: string;
  bio: string;
  ageRange: AgeRange | '';
  // ...
}

export interface ClientProfileFormViewModelReturn {
  fields: ClientProfileFormFields;
  isValid: boolean;
  fieldErrors: FieldErrors;
  handleFieldChange: <K extends keyof ClientProfileFormFields>(
    field: K,
    value: ClientProfileFormFields[K]
  ) => void;
  handleSubmit: (e: FormEvent) => Promise<void>;
}

ViewModel Layer

ViewModels are custom hooks that manage state, validation, and actions. They return data and callbacks only—never JSX.

// viewmodels/useClientProfileFormViewModel.ts
export function useClientProfileFormViewModel(
  props: UseClientProfileFormViewModelProps
): ClientProfileFormViewModelReturn {
  const { initialData, onSubmit, isSubmitting = false } = props;

  // Form state
  const [fields, setFields] = useState<ClientProfileFormFields>(() =>
    initializeFields(initialData)
  );

  // Sync with prop changes (controlled form behavior)
  useEffect(() => {
    setFields(initializeFields(initialData));
  }, [initialData]);

  // Validation
  const fieldErrors = useMemo(() => validateFields(fields), [fields]);
  const isValid = Object.keys(fieldErrors).length === 0;

  // Actions
  const handleFieldChange = useCallback(/* ... */);
  const handleSubmit = useCallback(/* ... */);

  return {
    fields,
    isValid,
    fieldErrors,
    handleFieldChange,
    handleSubmit,
    // ... other computed values and actions
  };
}

ViewModel Rules:

  1. Never return JSX
  2. Return only data and callbacks
  3. Compose multiple data sources (props, hooks, local state)
  4. Side effects via returned intents or useEffect
  5. All business logic lives here

View Layer

Views are pure presentation components. They receive all data via props and contain no business logic.

// views/ClientProfileFormView.tsx
export const ClientProfileFormView: FC<ClientProfileFormViewModelReturn> = ({
  fields,
  isValid,
  fieldErrors,
  handleFieldChange,
  handleSubmit,
  isSubmitting,
}) => {
  return (
    <Form onSubmit={handleSubmit}>
      <Input
        value={fields.displayName}
        onChange={(e) => handleFieldChange('displayName', e.target.value)}
        error={fieldErrors.displayName}
      />
      {/* ... */}
    </Form>
  );
};

View Rules:

  1. No hooks except local UI state (hover, focus, animations)
  2. All data via props
  3. No API calls, validation, or business logic
  4. Styled components are OK (presentation concern)
  5. No direct imports from model/constants (pass via ViewModel)

Container Layer

Containers are thin glue that connects ViewModel to View. They maintain backward-compatible APIs.

// components/ClientProfileForm.tsx
export const ClientProfileForm: FC<ClientProfileFormProps> = ({
  initialData,
  onSubmit,
  isSubmitting = false,
}) => {
  const viewModel = useClientProfileFormViewModel({
    initialData,
    onSubmit,
    isSubmitting,
  });

  return <ClientProfileFormView {...viewModel} />;
};

Container Rules:

  1. Thin glue layer only (< 50 lines typically)
  2. Connect ViewModel to View
  3. Handle error boundaries if needed
  4. Maintain backward-compatible props interface

Hierarchy: Page → Container → ViewModel + View

Page (route)
  └── Container (ClientProfileForm)
        ├── ViewModel Hook (useClientProfileFormViewModel)
        └── View (ClientProfileFormView)
  • Page: Route-level component, handles layout and data fetching
  • Container: Feature component, connects ViewModel to View
  • ViewModel: Business logic hook
  • View: Pure presentation

Testing Strategy

Layer Test Type Config Location
Model (schemas) Unit vitest model/*.test.ts
ViewModel Hook unit tests vitest browser mode viewmodels/*.spec.tsx
View Visual/snapshot Playwright views/*.spec.tsx
Container Integration vitest browser mode components/*.spec.tsx

ViewModel Testing

Use .spec.tsx extension for browser mode (avoids jsdom memory issues with React 19):

// viewmodels/useClientProfileFormViewModel.spec.tsx
import { renderHook, act } from '@testing-library/react';

describe('useClientProfileFormViewModel', () => {
  it('should validate displayName is required', () => {
    const { result } = renderHook(() =>
      useClientProfileFormViewModel({ onSubmit: vi.fn() })
    );

    expect(result.current.isValid).toBe(false);
    expect(result.current.fieldErrors.displayName).toBe('Display name is required');
  });
});

Testing Gotcha: When testing with renderHook, create mock objects outside the callback to avoid infinite re-renders:

// ❌ BAD - creates new object on every render, triggers useEffect loop
const { result } = renderHook(() =>
  useClientProfileFormViewModel({
    initialData: createMockProfile(), // New reference each render!
  })
);

// ✅ GOOD - stable reference
const profile = createMockProfile();
const { result } = renderHook(() =>
  useClientProfileFormViewModel({
    initialData: profile,
  })
);

Migration Checklist

When refactoring a component to MVVM:

  1. Identify business logic in the component (state, validation, transformations)
  2. Create model layer:
    • Extract constants to model/constants.ts
    • Define types in model/types.ts
    • Add Zod schemas if validation is complex
  3. Create ViewModel:
    • Move all useState, useMemo, useCallback to ViewModel hook
    • Return data + callbacks interface
  4. Create View:
    • Move JSX to View component
    • Remove all hooks except local UI state
    • Accept ViewModel return type as props
  5. Update Container:
    • Reduce to thin glue connecting ViewModel → View
    • Maintain backward-compatible props
  6. Add tests:
    • ViewModel unit tests (.spec.tsx for browser mode)
    • Verify existing integration tests pass
  7. Update exports in feature index.ts

Examples

Reference Implementation

See marketplace/frontend-public/src/features/client-profile/ for the complete pattern:

  • model/constants.ts - AGE_RANGES, SERVICE_INTERESTS, FIELD_LIMITS
  • model/types.ts - ClientProfileFormFields, ViewModelReturn
  • viewmodels/useClientProfileFormViewModel.ts - Business logic
  • views/ClientProfileFormView.tsx - Pure presentation
  • components/ClientProfileForm.tsx - Thin container (49 lines)

Before/After Comparison

Before (mixed concerns, 403 lines):

export const ClientProfileForm = ({ onSubmit }) => {
  const [displayName, setDisplayName] = useState('');
  const [bio, setBio] = useState('');
  // ... 20+ useState calls
  // ... validation logic
  // ... API transformation
  // ... 300+ lines of JSX with inline logic
};

After (separated concerns):

// Container (49 lines)
export const ClientProfileForm = ({ onSubmit }) => {
  const vm = useClientProfileFormViewModel({ onSubmit });
  return <ClientProfileFormView {...vm} />;
};

// ViewModel (193 lines) - all business logic
// View (270 lines) - all presentation

When to Apply MVVM

Apply MVVM when:

  • Component has complex form state
  • Component has validation logic
  • Component transforms data before submission
  • Component manages multiple related pieces of state
  • Component exceeds 200 lines with mixed concerns

Skip MVVM for:

  • Simple presentational components
  • Components with no state
  • Components with trivial state (single boolean toggle)
  • Wrapper/layout components