You call setState, the value changes in console.log, but the screen just sits there unchanged. If you have spent an afternoon staring at a Rork-generated app wondering why your updates do not land on the UI, you already know this particular flavour of frustration. Infinite loops are at least loud — this failure mode is quiet, and quiet bugs tend to eat hours.
I once lost half a day on a prototype before remembering that React compares state by reference, not by value. Once that clicked, the symptom made perfect sense. There are only a handful of patterns that produce the "setState did nothing" bug, and once you recognise them, the fix becomes a reflex.
Cause 1: You Mutated an Array or Object In Place
This is by far the most common one. Ask Rork to "append an item to the list" and every so often you will see code that uses .push():
// ❌ The UI never updates
const [items, setItems] = useState<string[]>([]);
const addItem = (text: string) => {
items.push(text); // mutates the existing array
setItems(items); // same reference, React sees no change
};React compares the previous and next state with Object.is. Because items.push() mutates the same array instance, the reference is unchanged and React decides nothing happened. The log shows the new contents, but no re-render is scheduled.
// ✅ Produce a new array
const addItem = (text: string) => {
setItems((prev) => [...prev, text]);
};Objects follow the same rule: user.name = 'new' is invisible to React. Use setUser({ ...user, name: 'new' }) instead. When prompting Rork, adding "update immutably" to your instructions is usually enough to steer the output away from mutation.
Cause 2: A Stale Closure Has Your State Frozen in Time
Any function defined inside a component — effect callbacks, timers, event handlers — captures the state values that existed the moment it was created. If dependencies are wrong, that closure holds onto an old value forever.
// ❌ count is always 0
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);With an empty dependency array, the interval callback captures count === 0 once and keeps calling setCount(0 + 1) every second. The state flips from 0 to 1 and sits there.
// ✅ Read the latest value via the updater form
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);The functional form of setState receives the current state at call time, so closures are no longer a concern. This is the mirror image of the trap covered in Why useEffect Loops Infinitely in Rork-Generated Code — two sides of the same closure problem.
Cause 3: A Zustand Selector That Returns a Fresh Object Every Time
When Rork generates Zustand code, you will occasionally see this shape:
// ❌ New object on every render — unstable subscriptions
const { user, setUser } = useUserStore((s) => ({
user: s.user,
setUser: s.setUser,
}));The selector returns a brand new object on each call. With Zustand's default shallow equality, this can either trigger unnecessary re-renders or — depending on what else changes in the store — mask real updates. The component ends up either flickering or frozen, and both look like "state is broken" from the outside.
// ✅ Pull primitives and functions one at a time
const user = useUserStore((s) => s.user);
const setUser = useUserStore((s) => s.setUser);Alternatively, pass shallow as the equality comparator when you really do need to return an object. For deeper structural patterns, Rork App State Management Patterns works through the architectures that keep large apps stable.
In practice I prefer keeping every Zustand subscription as narrow as possible — one primitive per useStore call — and only widening the selector when profiling shows it matters. That default keeps re-renders predictable, and it makes AI-generated store code easier to review: a one-liner selector is obvious, while a destructured object selector always needs a second look.
Cause 4: Missing Dependencies in React.memo or useMemo
When you reach for memoisation for performance reasons, an incomplete dependency array quietly freezes computed values at their first result.
// ❌ filteredList never recomputes, even when items changes
const filteredList = useMemo(
() => items.filter((i) => i.active),
[]
);Enabling the react-hooks/exhaustive-deps ESLint rule the moment you scaffold a Rork project catches almost every instance of this at edit time. It is the single highest-leverage lint rule in React Native work.
// ✅ Dependencies declared honestly
const filteredList = useMemo(
() => items.filter((i) => i.active),
[items]
);A related trap: wrapping a component in React.memo only helps if the props you pass in are stable. If the parent rebuilds onPress={() => doThing(id)} on every render, the memoised child still re-renders. Stabilise those callbacks with useCallback on the parent side.
Cause 5: setState After the Component Has Unmounted
If you kick off a fetch, navigate away before it resolves, and then call setState in the resolved promise, the update targets a component that no longer exists. You will often see a yellow warning like Can't perform a React state update on an unmounted component in Metro — that is your clue.
useEffect(() => {
let alive = true;
(async () => {
const data = await fetch('/api/items').then((r) => r.json());
if (!alive) return; // bail out after unmount
setItems(data);
})();
return () => { alive = false; };
}, []);AbortController is an even cleaner option if your fetch layer supports it, because you cancel the request itself. For list-rendering symptoms that can look similar, FlatList Renders Blank in Rork? walks through the blank-list debugging path.
A Three-Minute Triage Order
Rather than guessing, step through these checks in order:
- 1. Did the value actually change? Log
prevandnextbefore the update. Ifprev === nextby reference, you are in Cause 1. - 2. Is there a stale closure? Scan
useEffect,setTimeout, and inline handlers for direct state reads; swap them for the updater form ofsetState. - 3. Is the component re-rendering at all? Drop a
console.log('render', props)at the top of the component. If it never fires, the problem is upstream — memo, selector, or store subscription. - 4. Parent or child state? Make sure the update is hitting the owner of the state, not a local copy in a child.
- 5. Platform-specific? iOS-only or Android-only reproductions deserve their own investigation; cross-platform behaviour differences are a distinct bucket of bugs.
The order matters. Skipping ahead to React DevTools or the profiler before confirming that the value itself changed has burned me more than once. Start with the cheapest check (a console.log) and only reach for heavier tooling when the simple signal is ambiguous.
Small Habits That Prevent the Next Occurrence
A few routines save me from the same bug twice:
Turn on react-hooks/exhaustive-deps on day one of any Rork project. Fix the warnings all at once while the codebase is small; coming back later to untangle them is much slower.
Adopt the rule that any state update that depends on the previous state uses the functional form — setX((prev) => …). Even when you think you do not need it, writing it consistently eliminates an entire class of closure bugs.
When using Zustand or Context, audit every selector. Pull primitives individually, and only return an object with shallow when you have a real reason to.
Run your current bug through the three-minute triage order from the top — nine times out of ten you will land on Cause 1 or Cause 2. Going from "lost afternoon" to "ten-minute fix" is the kind of workflow shift that changes how it feels to build with Rork.