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

PrincipleMeaningApplication
S — Single ResponsibilityOne reason to changeEach ViewModel handles ONE concern (stats, filters, table)
O — Open/ClosedOpen for extension, closed for modificationBase hooks extended via composition, never modified
L — Liskov SubstitutionSubtypes must be substitutableAll ViewModels return consistent typed interfaces
I — Interface SegregationNo client forced to depend on unused interfacesComponents receive only the props they need
D — Dependency InversionDepend on abstractionsViews 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.

CRUD List Page StructureText
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 UI

Scenario 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 Page StructureText
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.tsx

Scenario 3: Detail / Profile Page

Use for viewing a single entity with tabs and sections. The orchestrator fetches the main entity and composes tab ViewModels.

Profile Orchestrator ViewModelTypeScript
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.

Wizard ViewModel PatternTypeScript
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

  1. Views are pure UI — No state, no logic, no mutations
  2. ViewModels handle ALL logic — State, mutations, computed values
  3. One ViewModel per concern — Statistics, Filters, Table = separate hooks
  4. Orchestrator composes — Main ViewModel composes section ViewModels
  5. Columns defined in ViewModel — Not in View or Component
  6. Max ~200 lines per View — If longer, extract section components
  7. No JSX in ViewModels — ViewModels return data, not UI