"The API is clearly returning data — I can see it in the logs — but the spinner just keeps spinning." If you've built an app with Rork and done real-device testing, you've probably run into this exact situation at least once.
No error message. Logs look fine. But the screen doesn't update. And when you ask the AI to fix it, the same problem keeps coming back. That's what makes this category of bug so frustrating — it hides well.
This article breaks down the most common reasons loading states get stuck in Rork-generated React Native code, and shows you exactly how to fix each one. Even if you're not comfortable reading the code yourself, you'll know what to tell Rork to change.
Why This Happens
Rork generates async/await code for data fetching. The flow looks like this:
- Screen mounts
isLoadingis set totrue, spinner appears- Data is fetched from an API or database
isLoadingis set tofalse, spinner disappears and data renders
The problem is step 4. If something goes wrong during step 3 — a network error, an empty response, an unexpected data structure — step 4 might never execute. When isLoading never gets set back to false, the spinner runs forever.
Pattern 1: Loading Never Clears on Error
This is the most common cause. The code uses try/catch, but doesn't include a finally block.
// Problem: if an error is thrown, setIsLoading(false) is never reached
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
setItems(data);
setIsLoading(false); // skipped entirely if an error occurs above
} catch (error) {
console.error(error);
// setIsLoading(false) is missing here
}
};When an error is thrown, execution jumps to catch, and setIsLoading(false) inside try is never called. The fix is a finally block.
// Fix: finally runs whether try succeeds or catch fires
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
setItems(data);
} catch (error) {
console.error('Fetch error:', error);
setError('Failed to load data');
} finally {
setIsLoading(false); // always executes, success or failure
}
};finally runs no matter what — whether try completes successfully or catch handles an error. Always put your loading reset here.
To ask Rork to fix this: "Update the fetchData function to move setIsLoading(false) into a finally block so it always runs, even when an error occurs."
Pattern 2: Conditional Branches That Skip the Loading Reset
When you branch on the fetched data, some code paths can miss the loading reset entirely.
// Problem: when data is empty, setIsLoading(false) is never called
const fetchUserData = async (userId: string) => {
setIsLoading(true);
const data = await getUserData(userId);
if (data && data.length > 0) {
setUserList(data);
setIsLoading(false); // only called if data has items
}
// if data is empty, loading never clears
};Move the loading reset outside the conditional, or wrap everything in try/finally.
// Fix: loading clears regardless of what data looks like
const fetchUserData = async (userId: string) => {
setIsLoading(true);
try {
const data = await getUserData(userId);
if (data && data.length > 0) {
setUserList(data);
} else {
setEmptyMessage('No users found');
}
} catch (error) {
setError('Failed to load users');
} finally {
setIsLoading(false);
}
};Pattern 3: Infinite Loop from useEffect Dependency Array
If loading starts, stops, then immediately starts again — you probably have a useEffect dependency issue.
// Problem: lastFetched is updated inside the effect, triggering it again endlessly
useEffect(() => {
fetchData();
setLastFetched(new Date());
}, [lastFetched]); // every time lastFetched changes, this fires againWhen a value in the dependency array gets modified inside the effect itself, it creates an infinite loop.
// Run only once when the component mounts
useEffect(() => {
fetchData();
}, []);
// Run only when a specific value changes (that isn't modified inside the effect)
useEffect(() => {
if (userId) {
fetchData(userId);
}
}, [userId]); // userId changes trigger a re-fetch, but userId isn't set inside this effectCheck your dependency array: if any value listed there is also being updated inside the same useEffect, that's your infinite loop.
Pattern 4: Response Structure Doesn't Match What the Code Expects
Sometimes the API returns 200, data arrives, and there's still nothing on screen. The fetched data's shape doesn't match what the code is trying to access.
// API response shape: { success: true, result: { items: [...] } }
// Wrong — data.items is undefined
const data = await response.json();
setItems(data.items);
// Correct
setItems(data.result.items);The fastest way to diagnose this is console.log.
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
// Log the actual shape so you can see what's coming back
console.log('API response:', JSON.stringify(data, null, 2));
// Then update the accessor to match what you see
setItems(data.result.items);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};Check the output in the Rork Companion console or your Expo dev server terminal. Once you know the actual structure, you can tell Rork: "Update the setItems call to use data.result.items instead of data.items."
Pattern 5: setState Called After Component Unmounts
If you navigate away from a screen quickly, you might see: "Can't perform a React state update on an unmounted component." This means an async operation completed after the component was already gone.
In modern React/Expo, this is often just a warning rather than a hard failure, but it can cause memory leaks and subtle state bugs in complex apps.
// Fix: track mount state and skip setState if already unmounted
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
setIsLoading(true);
try {
const data = await getData();
if (isMounted) {
setItems(data);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchData();
return () => {
isMounted = false; // cleanup runs when component unmounts
};
}, []);To request this from Rork: "Add an isMounted flag to the useEffect so that setState calls are skipped if the component has already unmounted. Return a cleanup function that sets isMounted to false."
How to Tell Rork What to Fix
A few prompts that reliably get the right changes:
For a stuck loading spinner: "The loading state never clears when an error occurs. Add a finally block to the fetchData function and move setIsLoading(false) into it."
For infinite re-fetching: "The useEffect is running in a loop. Check the dependency array and remove any values that are being modified inside the effect. Use an empty array if the fetch should only run once."
For missing data: "Add console.log(JSON.stringify(data, null, 2)) inside fetchData after the await, then tell me what the API response structure looks like."
Debugging Checklist
When loading won't stop or UI won't update, work through these in order.
First, look for setIsLoading(false) calls that only exist inside try, not in finally. Moving them to finally fixes the majority of stuck-spinner bugs.
Second, add a console.log to see the actual API response. Mismatched data structures are more common than you'd expect, and the log reveals them immediately.
Third, audit your useEffect dependency arrays. Any value in the array that's also being set inside the effect is a potential infinite loop.
If none of those find it, ask Rork to show you the entire fetchData function so you can trace the data flow from fetch to render.
Async bugs appear in AI-generated code and hand-written code alike. "It's not erroring, it just doesn't work" almost always comes down to one of these patterns. Work through them one at a time and you'll find it.