Loading and Skeletons
ActivityIndicator, skeletons shaped like the layout they stand in for, and why a full-screen spinner is usually the worst choice.
<ActivityIndicator animating size="small" color="#999999" />Two different jobs get muddled here. One is telling the user that something is happening. The other is holding the space that content will land in. ActivityIndicator does the first. A skeleton does the second.
ActivityIndicator
It draws the platform's spinner and takes four props. animating defaults to true. size is 'small' or 'large', and the numeric form is documented as Android only. color defaults to #999999 on iOS and to the system accent colour on Android. hidesWhenStopped is iOS only and defaults to true.
Why not a spinner over the whole screen
It throws away everything already on screen, so the user loses their place and learns nothing about what is coming. For a load that finishes in under about a fifth of a second it is a flash. And when the content arrives the layout jumps, because a centred spinner is nothing like the layout it replaced.
Skeletons
A skeleton is plain Views with a grey background, laid out with the same heights, padding and gaps as the real rows. Build one by copying the real component and swapping each Text for a sized box. There are no CSS keyframes here, so a shimmer needs the animation APIs covered later; a static skeleton already beats a spinner.
Keep existing content on screen while refreshing it. A small indicator in the header says enough.
Example
import { useEffect, useState } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
type Contact = { id: string; name: string; role: string };
const PLACEHOLDER_KEYS = ['a', 'b', 'c', 'd', 'e'];
export default function ContactList() {
// null means "not loaded yet", which is not the same as an empty list
const [contacts, setContacts] = useState<Contact[] | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
setContacts([
{ id: '1', name: 'Ada Lovelace', role: 'Analyst' },
{ id: '2', name: 'Grace Hopper', role: 'Compilers' },
]);
}, 600);
return () => clearTimeout(timer);
}, []);
if (contacts === null) {
return (
<View style={styles.screen}>
{PLACEHOLDER_KEYS.map((key) => (
// same row padding and box heights as the loaded row below
<View key={key} style={styles.row}>
<View style={[styles.block, styles.nameBlock]} />
<View style={[styles.block, styles.roleBlock]} />
</View>
))}
</View>
);
}
return (
<FlatList
style={styles.screen}
data={contacts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.name}>{item.name}</Text>
<Text style={styles.role}>{item.role}</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
screen: { flex: 1 },
row: { gap: 6, paddingHorizontal: 16, paddingVertical: 12 },
name: { fontSize: 16, lineHeight: 20 },
role: { color: '#666666', fontSize: 13, lineHeight: 16 },
block: { backgroundColor: '#e3e3e3', borderRadius: 4 },
nameBlock: { height: 20, width: '60%' },
roleBlock: { height: 16, width: '35%' },
});When to use it
- Fill a list screen with row-shaped placeholders on first load.
- Show progress inside a Save button without blocking the rest of the form.
- Refresh a screen that already has content without clearing it.
- Hold the layout for an image or avatar that has not decoded yet.
More examples
The indicator inside the button that caused the wait
Tapping Save replaces the label with a white spinner in place; the button keeps its size because the height is fixed rather than driven by the text. disabled={busy} is doing real work here, since a second tap would start a second submission. Use this shape for anything the user triggered deliberately: the feedback belongs where they touched, not over the whole screen.
import { useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native';
type SubmitButtonProps = {
onSubmit: () => Promise<void>;
};
export default function SubmitButton({ onSubmit }: SubmitButtonProps) {
const [busy, setBusy] = useState(false);
const press = async () => {
setBusy(true);
try {
await onSubmit();
} finally {
// finally, so a thrown error still releases the button
setBusy(false);
}
};
return (
<Pressable style={styles.button} onPress={press} disabled={busy}>
{busy ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.label}>Save</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: '#1f6feb',
borderRadius: 8,
height: 44, // fixed, so swapping label for spinner cannot resize it
justifyContent: 'center',
},
label: { color: '#ffffff', fontSize: 16 },
});A reusable skeleton box
One small component removes the repetition once more than one screen has a loading state. The percentage string type matches what React Native accepts for width, and strict mode will reject a plain string such as "70px", which does not exist here. Keep the grey in your theme rather than in this file so dark mode changes it in one place.
import { StyleSheet, View } from 'react-native';
type SkeletonProps = {
width: number | `${number}%`;
height: number;
radius?: number;
};
export default function Skeleton({ width, height, radius = 4 }: SkeletonProps) {
// sizes come from props, so this style has to be inline: StyleSheet.create
// runs once at module load and knows nothing about props
return <View style={[styles.base, { width, height, borderRadius: radius }]} />;
}
const styles = StyleSheet.create({
base: { backgroundColor: '#e3e3e3' },
});
// used inside a card's loading branch:
// <Skeleton width="70%" height={20} />
// <Skeleton width="40%" height={14} />
// <Skeleton width={64} height={64} radius={32} />Content stays, the header says it is refreshing
The second load of a screen is not the first one. Once rows exist, replacing them with a skeleton or a spinner is a regression: the user loses their scroll position and their reading place. A small indicator next to the title carries the same information. Note the ternary ends in null rather than false, which keeps the branch obviously a node and avoids rendering a stray value.
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
type ListHeaderProps = {
title: string;
refreshing: boolean;
};
export default function ListHeader({ title, refreshing }: ListHeaderProps) {
return (
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{/* the list below keeps its rows and its scroll position */}
{refreshing ? <ActivityIndicator size="small" /> : null}
</View>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row', // column is the default, so a row must be declared
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
title: { fontSize: 20, fontWeight: '600' },
});
Discussion