Debugging UI and State Issues with Practical Troubleshooting Habits

Capítulo 12

Estimated reading time: 12 minutes

+ Exercise

What “UI and State Bugs” Look Like in Real React Native Apps

Most React Native bugs that feel “mysterious” are actually predictable mismatches between what you think the UI should render and what the component tree is currently rendering based on state, props, and asynchronous events. UI and state issues often show up as: a button that sometimes doesn’t respond, a list that doesn’t refresh, a loading spinner that never stops, a screen that briefly flashes old data, a modal that won’t close, or an input that loses focus and resets.

To debug these effectively, you need two things: (1) a mental model of how render cycles and state updates interact with async work, and (2) a repeatable troubleshooting routine that reduces guesswork. This chapter focuses on practical habits and step-by-step approaches you can apply immediately.

Common categories of UI/state issues

  • Stale state: UI shows old values because updates are not applied where you think they are, or because closures captured older values.
  • Incorrect derived UI: UI is computed from state/props incorrectly (e.g., wrong dependency list, wrong memoization, wrong key usage).
  • Race conditions: multiple async operations complete out of order and overwrite each other.
  • Uncontrolled vs controlled inputs: TextInput value not aligned with state, causing resets or lag.
  • Layout/measurement surprises: elements overlap, disappear, or are not tappable due to zIndex, absolute positioning, or parent constraints.
  • Re-render storms: excessive re-renders cause jank, flicker, or lost input focus.
  • Navigation-driven state resets: screen unmount/remount or focus changes cause state to reset unexpectedly.

A Practical Debugging Mindset: Reduce the Problem Before You Solve It

When a UI/state bug appears, avoid immediately “trying fixes.” Instead, reduce the problem into a small, testable statement: “When I do X, I expect Y, but I get Z.” Then isolate whether the issue is (a) state not updating, (b) state updating but UI not reflecting it, or (c) UI reflecting it but something else overwrites it later.

Habit 1: Make the bug reproducible

If you can’t reproduce it reliably, you can’t verify the fix. Create a minimal set of steps that triggers it, and write them down. If it’s intermittent, look for timing triggers: slow network, rapid taps, background/foreground transitions, navigation back-and-forth, or orientation changes.

Habit 2: Observe state transitions, not just UI

UI is the symptom. The cause is often a state transition you didn’t expect. Add temporary logging around the state update points and around renders to confirm what values are actually used.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

Habit 3: Change one thing at a time

UI/state bugs often involve multiple moving parts. If you change several things at once, you won’t know which change fixed it (or which change introduced a new bug). Keep changes small and reversible.

Step-by-Step Routine: A Repeatable Troubleshooting Checklist

Step 1: Confirm whether the component is re-rendering

If the UI doesn’t update, first check whether the component re-rendered after the supposed state change. Add a render log at the top of the component.

function ProfileHeader({ userId }) {  console.log('ProfileHeader render');  // ...  return (...);}

If you never see the log after an action, your state update might not be happening, or the state lives elsewhere than you think (or the component is memoized and not re-rendering due to props equality).

Step 2: Log the values used for rendering

Log the exact values that drive conditional rendering, text, disabled states, and visibility. Don’t log “some state”; log the state that matters.

console.log('isLoading:', isLoading, 'items.length:', items.length, 'error:', error);

This quickly tells you whether the UI is wrong or your assumptions about state are wrong.

Step 3: Verify the state update path

Find the single source of truth for the value. Identify: (1) where it is set, (2) what triggers the set, and (3) whether anything else sets it later.

A useful technique is to temporarily wrap setters with a logging function.

const setLoadingLogged = (value) => {  console.log('setIsLoading ->', value);  setIsLoading(value);};

Step 4: Check for stale closures and incorrect dependencies

Many “state didn’t update” bugs are actually “the callback used an old value.” This happens when a function is created once and later called, but it captured state from an earlier render.

Symptoms include: tapping a button uses an old counter value, refreshing uses an old filter, or an interval keeps using the initial state.

One fix is to use functional updates when the new state depends on the previous state.

// Risky if 'count' might be stale in this closure:setCount(count + 1);// Safer:setCount((prev) => prev + 1);

For effects and memoized callbacks, verify dependency arrays. If you reference a value inside an effect/callback, it likely needs to be in the dependency list (or you need a different pattern).

useEffect(() => {  // uses userId  fetchUser(userId);}, [userId]);

Step 5: Look for “double writers” and race conditions

If state seems to update correctly and then “reverts,” you likely have two different code paths writing to the same state. Common sources: multiple fetches, refresh + initial load, focus effects, or optimistic updates plus server reconciliation.

To debug, log each write with a label.

const setItemsFromInitialLoad = (data) => {  console.log('items set by initial load', data.length);  setItems(data);};const setItemsFromRefresh = (data) => {  console.log('items set by refresh', data.length);  setItems(data);};

If logs show refresh finishes first but initial load finishes later and overwrites it, you have an out-of-order completion problem.

Step 6: Validate list keys and identity

UI glitches in lists (wrong row updates, flickering, incorrect item content) often come from unstable keys. If you use array index as a key and the list changes order or items are inserted/removed, React may reuse row components incorrectly.

Check that each item has a stable, unique key that does not change across renders.

<FlatList  data={items}  keyExtractor={(item) => item.id}  renderItem={...}/>

If you don’t have a stable id, create one when data is created (not during render), or restructure data to include one.

Step 7: Inspect layout constraints when touches or visibility are wrong

If a button looks visible but doesn’t respond, it might be covered by another view, or its parent might have pointerEvents set unexpectedly. If a view disappears, it might have zero height due to flex constraints or an ancestor with overflow: 'hidden'.

Practical checks:

  • Temporarily add background colors to suspect containers to see actual bounds.
  • Temporarily add borderWidth: 1 to visualize layout boxes.
  • Check for position: 'absolute' overlays (especially full-screen loaders).
  • Check zIndex and stacking context issues.
const styles = StyleSheet.create({  debugBox: { borderWidth: 1, borderColor: 'red' },});

Debugging Patterns with Practical Scenarios

Scenario 1: Loading spinner never stops

Symptom: You show a loader while fetching data, but it stays forever.

Step-by-step:

  • Log when loading starts and ends.
  • Ensure the “end loading” path runs on both success and failure.
  • Check if an early return skips the state update.
  • Check if multiple fetches are toggling loading incorrectly.
const load = async () => {  setIsLoading(true);  try {    const data = await api.getItems();    setItems(data);  } catch (e) {    setError(e);  } finally {    setIsLoading(false);  }};

Habit: Always use finally for loading flags when using async/await, so you don’t forget the error path.

Scenario 2: Button tap uses old state value

Symptom: You tap “Add to cart,” but it adds the previous quantity, not the current one.

Step-by-step:

  • Log the value at render time and at tap time.
  • If tap time shows an older value, suspect a stale closure.
  • Ensure the handler is recreated when dependencies change, or pass the current value directly.
const onAdd = () => {  console.log('tap quantity:', quantity);  addToCart(productId, quantity);};

If onAdd is memoized, verify dependencies:

const onAdd = useCallback(() => {  addToCart(productId, quantity);}, [addToCart, productId, quantity]);

Habit: When you memoize a callback, treat the dependency array as part of the function’s correctness, not just performance.

Scenario 3: FlatList shows wrong rows after deletion

Symptom: You delete an item, but the wrong row disappears or rows show mismatched content.

Step-by-step:

  • Check keyExtractor. If it uses index, fix it.
  • Confirm the data array is updated immutably (new array reference).
  • Log the ids before and after deletion.
const onDelete = (id) => {  setItems((prev) => prev.filter((x) => x.id !== id));};

Habit: Use immutable updates for arrays/objects so React can detect changes and reconcile correctly.

Scenario 4: TextInput loses focus or resets while typing

Symptom: As you type, the input jumps, loses focus, or clears.

Typical causes:

  • The component re-renders with a different key, forcing remount.
  • The input is controlled (value prop) but state updates are delayed or overwritten.
  • Parent renders a new element tree each keystroke (e.g., inline component definitions that change identity).

Step-by-step:

  • Ensure TextInput has a stable identity (avoid changing key).
  • Log the value passed to TextInput each render.
  • Confirm onChangeText updates the same state that drives value.
const [name, setName] = useState('');return (  <TextInput    value={name}    onChangeText={setName}    placeholder="Name"  />);

Habit: If an input behaves strangely, first verify it is not being remounted (stable key, stable conditional rendering around it).

Scenario 5: UI flickers between “empty” and “loaded” states

Symptom: On screen open, you briefly see “No items” before items appear.

Step-by-step:

  • Log initial state values.
  • Decide whether “empty” means “loaded but empty” or “not loaded yet.”
  • Use separate flags: isLoading and hasLoadedOnce, or use status enum-like state.
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'success' | 'error'useEffect(() => {  let isActive = true;  const run = async () => {    setStatus('loading');    try {      const data = await api.getItems();      if (!isActive) return;      setItems(data);      setStatus('success');    } catch (e) {      if (!isActive) return;      setError(e);      setStatus('error');    }  };  run();  return () => { isActive = false; };}, []);

Habit: Model UI states explicitly instead of inferring them from multiple loosely related variables.

Tools and Techniques That Make UI/State Bugs Easier to See

Strategic logging: render logs, event logs, and async logs

Random console.log spam is hard to interpret. Use structured logs with consistent prefixes and include identifiers.

console.log('[ItemsScreen] render', { status, count: items.length });console.log('[ItemsScreen] onRefresh start');console.log('[ItemsScreen] onRefresh success', { count: data.length });console.log('[ItemsScreen] onRefresh error', e);

This helps you reconstruct the timeline of events.

Use React DevTools to inspect props/state and re-renders

React DevTools can show the component tree and the current props/state for a selected component. When debugging, pick the component that renders the wrong UI and inspect the values it receives. If the values are wrong there, the bug is upstream. If the values are correct but UI is wrong, the bug is in rendering logic or layout.

Temporarily simplify rendering to isolate the bug

If a screen is complex, temporarily replace parts of the UI with a simple text dump of the relevant state. This is a powerful reduction technique.

<Text>{JSON.stringify({ status, items: items.map(i => i.id) }, null, 2)}</Text>

Once you confirm the state is correct, restore the UI and focus on layout/conditional rendering.

Use “binary search” on the component tree

If you don’t know where the bug originates, disable half the suspect logic and see if the bug persists. For example, comment out a memoization layer, remove an effect, or bypass a transformation step. Narrow down until you find the smallest piece that triggers the issue.

High-Value Habits to Prevent UI/State Bugs While You Build

Habit: Keep derived state minimal

Derived state is state that can be computed from other state/props. When you store derived values separately, they can drift out of sync. Prefer computing derived values during render (or memoizing them when necessary) instead of storing them in state.

Example: instead of storing filteredItems in state, store items and query, then compute filteredItems from them.

Habit: Treat async work as a timeline problem

Whenever you fetch, save, debounce, or delay, think: “What if another request starts before this one ends?” Add guards to prevent out-of-order updates from overwriting newer state. A simple pattern is to track a request id.

const requestIdRef = useRef(0);const load = async () => {  const requestId = ++requestIdRef.current;  setStatus('loading');  try {    const data = await api.getItems();    if (requestId !== requestIdRef.current) return;    setItems(data);    setStatus('success');  } catch (e) {    if (requestId !== requestIdRef.current) return;    setError(e);    setStatus('error');  }};

Habit: Make UI states mutually exclusive

Ambiguous combinations like “loading + error + empty” lead to confusing UI. Prefer a single status variable or carefully designed conditions so only one visual state is shown at a time.

Habit: Add small “debug affordances” during development

Examples include: showing a subtle debug panel with key state values, adding a “Reset screen state” button, or adding a “Simulate slow network” toggle in development builds. These are temporary but can dramatically speed up troubleshooting.

Layout-Specific Troubleshooting for React Native UI Issues

When something is invisible

  • Confirm it is actually rendered (render log).
  • Confirm it has size: check parent flex rules and explicit height/width.
  • Check if it is off-screen due to margins/positioning.
  • Check if it is behind another view (zIndex/absolute overlays).
  • Check opacity and background matching (white text on white background is common).

When something is visible but not tappable

  • Look for an overlay view capturing touches (full-screen loader, transparent modal backdrop).
  • Check pointerEvents on parent containers.
  • Check if the tappable area is actually zero-sized due to layout constraints.
  • Temporarily add borders to see the real hit box.

When animations or transitions cause state confusion

Animations can mask timing issues: a component unmounts while an async callback still tries to set state, or a transition triggers multiple renders. If you see warnings about setting state on an unmounted component, add cleanup logic in effects and ensure async callbacks check whether the component is still active before setting state (as shown earlier with isActive flags or request ids).

Now answer the exercise about the content:

A loading spinner stays visible forever after a data fetch. Which troubleshooting change best ensures the loading flag is turned off on both success and failure?

You are right! Congratulations, now go to the next page

You missed! Try again.

Using finally makes the end-loading path run for both success and failure, preventing the spinner from staying on when an error or early exit occurs.

Free Ebook cover React Native Basics: Components, Styling, and Navigation Concepts
100%

React Native Basics: Components, Styling, and Navigation Concepts

New course

12 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.