Loading, Error and Empty
Three booleans allow states that cannot happen; one tagged union gives loading, error and data exactly once each.
type State<T> = { status: 'loading' } | { status: 'error'; message: string } | { status: 'ready'; data: T };A screen that loads data has four things to draw: a spinner, an error with a way to retry, an empty message, and the data. The usual first attempt is three booleans — loading, error, data — and the problem is that eight combinations exist while only four are real. Code then grows guards for states that cannot happen, and still renders the wrong thing when one does.
One value instead
Model the outcome as a single tagged union: one status field, with the data that only makes sense for that status sitting next to it. "Loading and failed at once" becomes unwriteable, and TypeScript narrows each branch, so state.notes is only reachable once the status is ready.
Empty is not a request state
Empty is a successful response with no rows. It belongs inside the success branch, derived from the array's length — FlatList has ListEmptyComponent for exactly this. Keeping it separate matters because an empty array and a failed request look identical if you initialise data to [] and only track loading.
What trips people up
A refetch is not a first load. Swapping the list for a full-screen spinner every time data refreshes makes the app flicker; add a refreshing flag to the ready state instead. And an error branch with no retry control leaves the user on a dead screen — the branch that reports failure should also offer the fix.
Example
import { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
type Note = { id: string; body: string };
// One value, three shapes. "Loading and errored" cannot be expressed.
type State =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; notes: Note[] };
export default function NotesScreen() {
const [state, setState] = useState<State>({ status: 'loading' });
const load = useCallback(async () => {
setState({ status: 'loading' });
try {
const response = await fetch('https://api.example.com/notes');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setState({ status: 'ready', notes: (await response.json()) as Note[] });
} catch (e) {
setState({ status: 'error', message: (e as Error).message });
}
}, []);
useEffect(() => {
load();
}, [load]);
if (state.status === 'loading') return <ActivityIndicator style={styles.fill} />;
if (state.status === 'error') {
return (
<View style={styles.fill}>
<Text>{state.message}</Text>
<Pressable onPress={load}><Text>Try again</Text></Pressable>
</View>
);
}
// Empty lives here: a successful response that happens to have no rows.
return (
<FlatList
data={state.notes}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.body}</Text>}
ListEmptyComponent={<Text style={styles.row}>No notes yet</Text>}
/>
);
}
const styles = StyleSheet.create({
fill: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 8 },
row: { padding: 16 },
});When to use it
- Give a list screen a spinner, a retry button and an empty message without impossible in-between states.
- Distinguish "you have no orders" from "we could not load your orders".
- Keep rows on screen during a pull-to-refresh instead of flashing a spinner.
- Unit-test a screen's state transitions without rendering it.
More examples
The boolean version, and what it allows
Worth writing out once so the shape of the problem is visible. Each extra boolean doubles the state space while adding no new real outcome, and the reset logic grows with it. Reach for the union the moment a second flag appears.
// Eight combinations, four of them meaningless.
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Nothing stops these from being reached:
// loading === true && error !== null -> a spinner over a failure
// loading === false && error === null && notes.length === 0
// -> is that "empty" or "never asked"? The state cannot say.
// Every retry also has to reset each flag, and missing one line is a visible bug:
async function retry() {
setError(null); // forget this and the old error stays on screen
setLoading(true);
// ...fetch, then set notes and clear loading
}The same transitions as a pure reducer
Moving the transitions out of the component makes them testable without rendering anything: pass a state and an action, assert on the result. This is also where TypeScript earns its keep — with no default case, adding a fourth action type makes the compiler point straight at this switch.
type Note = { id: string; body: string };
type State =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; notes: Note[] };
type Action =
| { type: 'started' }
| { type: 'failed'; message: string }
| { type: 'loaded'; notes: Note[] };
export function notesReducer(_state: State, action: Action): State {
switch (action.type) {
case 'started':
return { status: 'loading' };
case 'failed':
return { status: 'error', message: action.message };
case 'loaded':
return { status: 'ready', notes: action.notes };
}
}
const loaded = notesReducer({ status: 'loading' }, { type: 'loaded', notes: [] });
console.log(loaded.status, loaded.status === 'ready' && loaded.notes.length);
// Output: ready 0
const failed = notesReducer(loaded, { type: 'failed', message: 'HTTP 500' });
console.log(failed.status);
// Output: errorRefetching without losing the rows
Reach for this once a screen can be refreshed as well as loaded. On screen the list stays exactly where it was and the platform spinner appears above it, instead of the whole list disappearing behind a centred spinner for half a second.
import { useState } from 'react';
import { FlatList, StyleSheet, Text } from 'react-native';
type Note = { id: string; body: string };
type State =
| { status: 'loading' }
| { status: 'error'; message: string }
// refreshing lives inside ready, so the rows stay mounted while it is true.
| { status: 'ready'; notes: Note[]; refreshing: boolean };
export default function NotesList({ initial }: { initial: Note[] }) {
const [state, setState] = useState<State>({
status: 'ready',
notes: initial,
refreshing: false,
});
async function refresh() {
if (state.status !== 'ready') return;
// Spread shorthand; the longhand is { status: 'ready', notes: state.notes, refreshing: true }
setState({ ...state, refreshing: true });
const response = await fetch('https://api.example.com/notes');
const notes = (await response.json()) as Note[];
setState({ status: 'ready', notes, refreshing: false });
}
if (state.status !== 'ready') return null;
return (
<FlatList
data={state.notes}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.body}</Text>}
refreshing={state.refreshing}
onRefresh={refresh}
/>
);
}
const styles = StyleSheet.create({ row: { padding: 16 } });
Discussion