SOLID View/ViewModel
Page type scenarios CRUD lists, dashboards, profiles, settings, wizards, and report builders.
The SOLID View/ViewModel pattern is mandatory for all pages in src/modules/. This guide covers 7 page type scenarios with their exact directory structures, ViewModel patterns, and code examples.
SOLID Principles Applied
| Principle | Meaning | Application |
|---|---|---|
| S — Single Responsibility | One reason to change | Each ViewModel handles ONE concern (stats, filters, table) |
| O — Open/Closed | Open for extension, closed for modification | Base hooks extended via composition, never modified |
| L — Liskov Substitution | Subtypes must be substitutable | All ViewModels return consistent typed interfaces |
| I — Interface Segregation | No client forced to depend on unused interfaces | Components receive only the props they need |
| D — Dependency Inversion | Depend on abstractions | Views depend on ViewModel hook interfaces, not implementations |
Page Type Scenarios
Choose the scenario that matches your page type. Each provides a tried-and-tested structure that ensures consistency across the entire application.
Scenario 1: CRUD List Page
Use for managing entity collections (Users, Products, Orders). The orchestrator composes statistics, filters, and table ViewModels.
user-management/src/presentation/
├── views/
│ └── UserManagementView.tsx # ~200 lines max, pure composition
├── viewmodels/
│ ├── useUserManagementViewModel.ts # Orchestrator
│ ├── useStatisticsViewModel.ts # Stats logic
│ ├── useFilterViewModel.ts # Filter state
│ └── useBlockUserAction.ts # Row action
└── components/
├── StatisticsSection.tsx # Stats UI
└── FilterSection.tsx # Filter UIScenario 2: Dashboard / Analytics
Use for KPIs, charts, and metrics. Each chart or card section gets its own ViewModel with period selection and data transformation.
dashboard/src/presentation/
├── views/
│ └── DashboardView.tsx # ~200 lines max
├── viewmodels/
│ ├── useDashboardViewModel.ts # Orchestrator
│ ├── useKPIViewModel.ts # KPI cards
│ ├── useChartViewModel.ts # Chart data + period
│ └── useRecentActivityViewModel.ts
└── components/
├── KPICards.tsx
├── SalesChart.tsx
└── RecentActivityTable.tsxScenario 3: Detail / Profile Page
Use for viewing a single entity with tabs and sections. The orchestrator fetches the main entity and composes tab ViewModels.
export function useUserProfileViewModel(userId: string) {
const [activeTab, setActiveTab] = useState("activity");
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => userRepository.getById(userId),
});
const header = useProfileHeaderViewModel(user);
const activity = useActivityTabViewModel(userId);
const settings = useSettingsTabViewModel(userId);
return {
user, isLoading, error,
activeTab, setActiveTab,
tabs: [
{ id: "activity", label: t("profile.tabs.activity") },
{ id: "settings", label: t("profile.tabs.settings") },
],
header, activity, settings,
};
}Scenario 4: Settings Page
Use for multiple independently-saveable form sections. Each settings section gets its own ViewModel with form state and save mutation.
Scenario 5: Wizard / Multi-Step Form
Use for complex multi-step flows like onboarding or checkout. The wizard ViewModel coordinates step navigation, validation gates, and combined submission.
export function useOnboardingViewModel() {
const [currentStep, setCurrentStep] = useState(1);
const totalSteps = 3;
const step1 = useStep1ViewModel();
const step2 = useStep2ViewModel();
const step3 = useStep3ViewModel();
const canGoNext = useMemo(() => {
if (currentStep === 1) return step1.form.formState.isValid;
if (currentStep === 2) return step2.form.formState.isValid;
return true;
}, [currentStep, step1, step2]);
const { mutate: submitAll, isPending: isSubmitting } = useMutation({
mutationFn: async () => {
const combined = {
...step1.form.getValues(),
...step2.form.getValues(),
...step3.form.getValues(),
};
return onboardingRepository.complete(combined);
},
});
return {
currentStep, totalSteps,
canGoNext, canGoPrev: currentStep > 1,
goNext: () => setCurrentStep(s => Math.min(s + 1, totalSteps)),
goPrev: () => setCurrentStep(s => Math.max(s - 1, 1)),
step1, step2, step3,
isSubmitting, submitAll,
};
}Golden Rules
- Views are pure UI — No state, no logic, no mutations
- ViewModels handle ALL logic — State, mutations, computed values
- One ViewModel per concern — Statistics, Filters, Table = separate hooks
- Orchestrator composes — Main ViewModel composes section ViewModels
- Columns defined in ViewModel — Not in View or Component
- Max ~200 lines per View — If longer, extract section components
- No JSX in ViewModels — ViewModels return data, not UI