Goal: Model UI as a function of state with a ViewModel-like hook and a discriminated-union state machine.
state
only.// view-model.ts
import { useEffect, useReducer, useCallback } from "react";
type State =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; items: string[] }
| { kind: "error"; message: string };
type Event =
| { type: "LOAD" }
| { type: "LOADED"; items: string[] }
| { type: "FAILED"; message: string };
function reducer(state: State, ev: Event): State {
switch (ev.type) {
case "LOAD":
return { kind: "loading" };
case "LOADED":
return { kind: "success", items: ev.items };
case "FAILED":
return { kind: "error", message: ev.message };
default:
return state;
}
}
export function useTodos(api: { list: () => Promise<string[]> }) {
const [state, dispatch] = useReducer(reducer, { kind: "idle" } as State);
const load = useCallback(async () => {
dispatch({ type: "LOAD" });
try {
const items = await api.list();
dispatch({ type: "LOADED", items });
} catch (e: any) {
dispatch({ type: "FAILED", message: e?.message ?? "Unknown error" });
}
}, [api]);
useEffect(() => {
if (state.kind === "idle") load();
}, [state.kind, load]);
return { state, load };
}
// TodosScreen.tsx
import React from "react";
import { View, Text, Button, ActivityIndicator, FlatList } from "react-native";
import { useTodos } from "./view-model";
export function TodosScreen({
api,
}: {
api: { list: () => Promise<string[]> };
}) {
const { state, load } = useTodos(api);
if (state.kind === "loading") return <ActivityIndicator />;
if (state.kind === "error")
return (
<View>
<Text>{state.message}</Text>
<Button title="Retry" onPress={load} />
</View>
);
if (state.kind === "success")
return (
<FlatList
data={state.items}
keyExtractor={(x) => x}
renderItem={({ item }) => <Text>{item}</Text>}
/>
);
return <Button title="Load" onPress={load} />;
}
Hook as ViewModel + simple screen using a fake API.
// app/api.ts
export type Api = { list: () => Promise<string[]> };
export const fakeApi: Api = {
async list() {
return ["Milk", "Bread", "Eggs"];
},
};
// app/useTodosVM.ts
import { useEffect, useReducer } from "react";
import type { Api } from "./api";
type State =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ok"; items: string[] }
| { kind: "err"; msg: string };
type Ev =
| { type: "LOAD" }
| { type: "OK"; items: string[] }
| { type: "ERR"; msg: string };
function reducer(s: State, e: Ev): State {
switch (e.type) {
case "LOAD":
return { kind: "loading" };
case "OK":
return { kind: "ok", items: e.items };
case "ERR":
return { kind: "err", msg: e.msg };
}
}
export function useTodosVM(api: Api) {
const [state, dispatch] = useReducer(reducer, { kind: "idle" } as State);
useEffect(() => {
if (state.kind === "idle") {
(async () => {
dispatch({ type: "LOAD" });
try {
const items = await api.list();
dispatch({ type: "OK", items });
} catch (e: any) {
dispatch({ type: "ERR", msg: e?.message ?? "Oops" });
}
})();
}
}, [state.kind, api]);
return { state };
}
// screens/TodosMVVM.tsx
import React from "react";
import { ActivityIndicator, FlatList, Text, View } from "react-native";
import { fakeApi } from "../app/api";
import { useTodosVM } from "../app/useTodosVM";
export function TodosMVVM() {
const { state } = useTodosVM(fakeApi);
if (state.kind === "loading") return <ActivityIndicator />;
if (state.kind === "err") return <Text>{state.msg}</Text>;
if (state.kind === "ok")
return (
<FlatList
data={state.items}
keyExtractor={(x) => x}
renderItem={({ item }) => <Text>{item}</Text>}
/>
);
return <View />;
}
Notes
fakeApi
with the real repo without changing the screen.Paste into an Expo app (see sandboxes/react-native-expo):