Add loading states and loaders to UI components and screens. Use when adding loaders, implementing loading states, improving UX during async operations, or when the user provides components that need loading indicators. Covers full-screen, button, inline, modal, list, refresh, and skeleton loaders for React Native and web apps.
Add appropriate loading indicators wherever async work occurs. Missing loaders cause confusion; overloading causes clutter. Every loader must include an actual spinner (ActivityIndicator in React Native); text like "Loading..." alone is not sufficient—it supplements the spinner. This skill defines loader types, when to use each, and implementation patterns.
| Type | When to Use | Example |
|---|---|---|
| Full-screen | Initial data fetch, no content yet | List screen before first load |
| Button | Form submit, single action | Login, Save, Submit |
| Inline | Section or card loading | Widget, partial content |
| Modal | Modal content fetching |
| Selector with async options |
| List | Pagination, infinite scroll | "Load more" at list bottom |
| Refresh | Pull-to-refresh | Swipe down on list |
| Skeleton | Content layout known | Cards, tables (optional) |
Async operation?
├── Initial load, no UI yet? → Full-screen loader
├── User-triggered action (button)? → Button loader
├── Part of screen loading? → Inline loader
├── Modal fetching data? → Modal content loader
├── Pagination / load more? → List footer loader
└── Pull-to-refresh? → RefreshControl
When: Screen depends on data; nothing meaningful to show until loaded.
Pattern:
if (loading && items.length === 0) {
return (
<ScreenLayout edges={['top']}>
<ScreenHeader title="..." showBack onBackPress={handleBack} />
<View className="flex-1 items-center justify-center px-4">
<ActivityIndicator size="large" color="#1E40AF" />
<Text className="text-[15px] text-[#64748B] mt-4">
Loading...
</Text>
</View>
</ScreenLayout>
);
}
Rules:
loading && data.length === 0 (not on refresh)size="large", primary colorWhen: User taps a button that triggers async work (submit, save, delete).
Pattern:
<TouchableOpacity
className={`rounded-[10px] h-[50px] flex-row items-center justify-center gap-2 ${
isLoading ? 'bg-[#1E40AF]/70' : 'bg-[#1E40AF]'
}`}
disabled={isLoading}
onPress={handleSubmit}
accessibilityLabel={isLoading ? 'Saving, please wait' : 'Save'}
accessibilityState={{ disabled: isLoading, busy: isLoading }}
>
{isLoading ? (
<>
<ActivityIndicator size="small" color="#FFFFFF" />
<Text className="text-[15px] font-semibold text-white">Please wait…</Text>
</>
) : (
<Text className="text-[15px] font-semibold text-white">Save</Text>
)}
</TouchableOpacity>
Rules:
disabled={isLoading} to prevent double-tapsize="small" for button, white color on primary buttonsaccessibilityState={{ busy: isLoading }} for screen readersopacity-70 or similar)When: A section (card, widget) loads independently; rest of screen is visible.
Pattern:
if (loading) {
return (
<View className="p-4 items-center justify-center min-h-[120px]">
<ActivityIndicator size="small" color="#1E40AF" />
<Text className="text-[13px] text-[#64748B] mt-2">Loading...</Text>
</View>
);
}
Rules:
size="small" to avoid dominating the sectionWhen: Modal opens and needs to fetch options (users, sites, categories).
Pattern:
// Inside modal body
{loading && items.length === 0 ? (
<View className="flex-1 items-center justify-center py-12">
<ActivityIndicator size="large" color="#1E40AF" />
<Text className="text-[15px] text-[#64748B] mt-4">
Loading options...
</Text>
</View>
) : (
<FlatList data={items} ... />
)}
Rules:
When: Loading more items at bottom of list.
Pattern:
<FlatList
data={items}
ListFooterComponent={
loadingMore ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" color="#1E40AF" />
</View>
) : null
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
/>
Rules:
size="small" so it doesn't dominateloadingMore; hide when done or no more dataWhen: User can refresh list/screen content.
Pattern:
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await dispatch(fetchItems()).unwrap();
setRefreshing(false);
}, [dispatch]);
<FlatList
data={items}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
colors={['#1E40AF']}
tintColor="#1E40AF"
/>
}
/>
Rules:
refreshing to false in finally to handle errorsWhen: Layout is known; skeleton improves perceived performance.
Pattern (conceptual):
// Placeholder blocks matching content layout
{loading ? (
<View className="gap-3">
{[1,2,3].map(i => (
<View key={i} className="h-20 bg-[#E2E8F0] rounded-[10px] animate-pulse" />
))}
</View>
) : (
<ActualContent />
)}
When auditing a component for loaders:
Identify async operations
Map to loader type
Add loading state
selectItemsLoading)useState(false) and set in async handlerloading is set true at start, false in finallyHandle error and empty
Accessibility
accessibilityState={{ disabled, busy }} on buttonsaccessibilityLabel that reflects loading (e.g. "Saving, please wait")ActivityIndicator — Every loading state must show an actual spinner. Text like "Loading..." alone is not a loader; it supplements the spinner.ActivityIndicator from react-native#1E40AF (primary), #FFFFFF (on dark buttons)large for full-screen/inline blocks, small for buttons/list footerView with flex-1 items-center justify-center for centered full-screen| Mistake | Fix |
|---|---|
| Text-only loading (no spinner) | Always include ActivityIndicator; text is optional supplement |
| Full-screen loader on every refresh | Use RefreshControl; full-screen only when data.length === 0 |
| No button disable during submit | disabled={isLoading} |
| Loader without accessibility | Add accessibilityState={{ busy }} |
| Loading state never reset on error | Use finally or catch to set loading false |
| Modal submit enabled while options load | Disable primary action when loading |
For CIAMS design tokens (colors, spacing), see ciams-design-system/SKILL.md.