ViewModel und Architektur

15 MinutenZustand und Daten sauber von der Oberfläche trennen

MVVM-Schichten

Jede Schicht hat eine Aufgabe und kennt nur die Schicht darunter.

MVVM-Schichten der Wetter-App mit einem Custom Hook als ViewModel
MVVM-Schichten der Wetter-App mit einem Custom Hook als ViewModel

UiState als Union-Typ

Der Screen ist immer in genau einem von drei Zuständen.

import type { City } from '@/model/city';
import type { CurrentWeather } from '@/model/current-weather';

export type WeatherUiState =
  | { status: 'loading' }
  | { status: 'success'; city: City; weather: CurrentWeather }
  | { status: 'error'; message: string };

ViewModel als Custom Hook

Ein Custom Hook ist eine Funktion, deren Name mit use beginnt und die selbst Hooks aufruft. Er hält den Zustand und gibt ihn zurück.

import { useState } from 'react';

import { fakeCities, fakeCurrentWeather } from '@/data/fake-data';
import type { WeatherUiState } from '@/ui/weather-ui-state';

export function useWeather(): WeatherUiState {
  const [uiState] = useState<WeatherUiState>({
    status: 'success',
    city: fakeCities[0],
    weather: fakeCurrentWeather[fakeCities[0].name],
  });

  return uiState;
}

Stateful und stateless

Eine Komponente holt den Zustand aus dem Hook, eine zweite zeigt ihn nur an.

export function WeatherScreen() {
  const uiState = useWeather();
  const [isCelsius, setIsCelsius] = useState(true);

  return (
    <WeatherContent
      uiState={uiState}
      isCelsius={isCelsius}
      onToggleUnit={() => setIsCelsius(!isCelsius)}
    />
  );
}

Repository

Das Repository liefert die Daten. Der Hook kennt nur den Typ.

import type { City } from '@/model/city';
import type { CurrentWeather } from '@/model/current-weather';

export type WeatherRepository = {
  getCurrentWeather(city: City): Promise<CurrentWeather>;
};

Repository

export function createFakeWeatherRepository({
  shouldFail = false,
} = {}): WeatherRepository {
  return {
    async getCurrentWeather(city) {
      await new Promise((resolve) => setTimeout(resolve, 1500));
      if (shouldFail) throw new Error('Keine Verbindung (simuliert)');
      // ... Fake-Daten zur Stadt zurückgeben
    },
  };
}

Laden mit useEffect

useEffect führt Code aus, nachdem die Komponente angezeigt wurde. Damit lädt der Hook die Daten.

useEffect(() => {
  let ignore = false;

  async function loadWeather() {
    const weather = await repository.getCurrentWeather(city);
    if (!ignore) setUiState({ status: 'success', city, weather });
    // bei Fehler: setUiState({ status: 'error', … })
  }

  loadWeather();
  return () => { ignore = true; }; // Aufräumen
}, [city, repository]);

Repository als Default-Parameter

Der Hook bekommt sein Repository als optionalen Parameter. Für die echte API ändert sich später nur eine Zeile.

const defaultRepository = createFakeWeatherRepository();

export function useWeather(
  city: City,
  repository: WeatherRepository = defaultRepository,
): WeatherUiState {