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:
- Never return JSX
- Return only data and callbacks
- Compose multiple data sources (props, hooks, local state)
- Side effects via returned intents or useEffect
- 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:
- No hooks except local UI state (hover, focus, animations)
- All data via props
- No API calls, validation, or business logic
- Styled components are OK (presentation concern)
- 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:
- Thin glue layer only (< 50 lines typically)
- Connect ViewModel to View
- Handle error boundaries if needed
- 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:
- Identify business logic in the component (state, validation, transformations)
- Create model layer:
- Extract constants to
model/constants.ts - Define types in
model/types.ts - Add Zod schemas if validation is complex
- Extract constants to
- Create ViewModel:
- Move all useState, useMemo, useCallback to ViewModel hook
- Return data + callbacks interface
- Create View:
- Move JSX to View component
- Remove all hooks except local UI state
- Accept ViewModel return type as props
- Update Container:
- Reduce to thin glue connecting ViewModel → View
- Maintain backward-compatible props
- Add tests:
- ViewModel unit tests (
.spec.tsxfor browser mode) - Verify existing integration tests pass
- ViewModel unit tests (
- 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_LIMITSmodel/types.ts- ClientProfileFormFields, ViewModelReturnviewmodels/useClientProfileFormViewModel.ts- Business logicviews/ClientProfileFormView.tsx- Pure presentationcomponents/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