AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Initial Render vs Re-Render in React 19: When and Why They Differ

Last updated:
Initial Render vs Re-Render in React 19: When and Why They Differ

Master the distinction between initial renders and re-renders in React. Learn lifecycle differences, hook behavior, and optimization strategies for both phases.

Table of Contents

# Initial Render vs Re-Render in React 19: When and Why They Differ

Every React component goes through two fundamentally different phases: the initial render (when it first mounts) and subsequent re-renders (when state or props change). But here's what many developers miss—these two phases aren't just different in frequency; they execute with different semantics, timing guarantees, and hook behaviors.

Understanding this distinction is critical for debugging performance issues, managing side effects correctly, and building resilient component architectures. Let's dig into what actually happens during each phase and how to leverage these differences.

# Table of Contents

  1. What Is Initial Render vs Re-Render?
  2. Key Semantic Differences
  3. Hook Behavior Across Phases
  4. useEffect: When Does It Actually Run?
  5. Layout Effects and Timing Guarantees
  6. The Render and Commit Phases
  7. Conditional Logic Based on Phase
  8. Real-World Patterns and Gotchas
  9. Performance Implications
  10. FAQ

# What Is Initial Render vs Re-Render? {#what-is}

Initial render (also called "mount") happens exactly once—when a component is first added to the React tree and React converts its JSX into actual DOM nodes.

Re-render (also called "update") happens any time after the initial render when state changes, props change, or a parent component re-renders.

This distinction matters because React optimizes for the common case (re-renders happen much more frequently than initial renders), and this affects what you can reliably do during each phase.

# TypeScript Version

typescript
import { useState, useEffect, useRef } from 'react';

interface CounterProps {
  startingValue?: number;
}

export function Counter({ startingValue = 0 }: CounterProps) {
  const [count, setCount] = useState(startingValue);
  const renderCountRef = useRef(0);
  const initialRenderRef = useRef(true);

  useEffect(() => {
    renderCountRef.current += 1;
    
    if (initialRenderRef.current) {
      console.log('🟢 INITIAL RENDER - component mounted');
      initialRenderRef.current = false;
    } else {
      console.log('🔄 RE-RENDER - state or props changed');
    }
  });

  return (
    <div>
      <p>Count: {count}</p>
      <p>Total renders: {renderCountRef.current}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

# JavaScript Version

javascript
import { useState, useEffect, useRef } from 'react';

export function Counter({ startingValue = 0 }) {
  const [count, setCount] = useState(startingValue);
  const renderCountRef = useRef(0);
  const initialRenderRef = useRef(true);

  useEffect(() => {
    renderCountRef.current += 1;
    
    if (initialRenderRef.current) {
      console.log('🟢 INITIAL RENDER - component mounted');
      initialRenderRef.current = false;
    } else {
      console.log('🔄 RE-RENDER - state or props changed');
    }
  });

  return (
    <div>
      <p>Count: {count}</p>
      <p>Total renders: {renderCountRef.current}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

The key insight: by the time your component function runs (whether it's initial or re-render), the semantic difference is already established internally by React. You detect it using side effects and refs.

# Key Semantic Differences {#semantic-differences}

Here's what changes between initial render and re-render:

Aspect Initial Render Re-Render
DOM Nodes Being created for the first time Already exist (may or may not change)
Hook Dependencies Empty array dependencies run on first render Run only when dependencies change
Reconciliation Always compares new component tree against empty tree Compares against previous tree
Cleanup Functions Not run (no previous effect to clean) Run before re-running effect
useLayoutEffect Runs after DOM mutations but before paint Runs after DOM mutations but before paint
useInsertionEffect Used for CSS-in-JS (before DOM updates) Same timing as initial render
Performance Includes DOM creation cost Usually just reconciliation

# TypeScript Version: Detecting the Phase

typescript
import { useEffect, useRef, ReactNode } from 'react';

interface LifecycleComponentProps {
  children?: ReactNode;
}

export function LifecycleComponent({ children }: LifecycleComponentProps) {
  const isInitialRenderRef = useRef(true);

  useEffect(() => {
    // This runs AFTER the component is rendered and mounted
    if (isInitialRenderRef.current) {
      console.log('Phase: INITIAL RENDER (component just mounted)');
      isInitialRenderRef.current = false;
    } else {
      console.log('Phase: RE-RENDER (state/props changed)');
    }
  });

  // Detect initial render SYNCHRONOUSLY (before effects)
  if (isInitialRenderRef.current) {
    console.log('Render phase: INITIAL (DOM not yet updated)');
  } else {
    console.log('Render phase: RE-RENDER (previous DOM exists)');
  }

  return <div>{children}</div>;
}

# JavaScript Version

javascript
import { useEffect, useRef } from 'react';

export function LifecycleComponent({ children }) {
  const isInitialRenderRef = useRef(true);

  useEffect(() => {
    if (isInitialRenderRef.current) {
      console.log('Phase: INITIAL RENDER (component just mounted)');
      isInitialRenderRef.current = false;
    } else {
      console.log('Phase: RE-RENDER (state/props changed)');
    }
  });

  if (isInitialRenderRef.current) {
    console.log('Render phase: INITIAL (DOM not yet updated)');
  } else {
    console.log('Render phase: RE-RENDER (previous DOM exists)');
  }

  return <div>{children}</div>;
}

# Hook Behavior Across Phases {#hook-behavior}

Different hooks have different behaviors depending on whether it's an initial render or re-render.

# useState

typescript
export function StateExample() {
  // On initial render: initializes to 0
  // On re-renders: ignores this argument and returns the current state
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

# useEffect

typescript
import { useEffect, useState } from 'react';

export function EffectExample() {
  const [data, setData] = useState(null);
  const [filter, setFilter] = useState('');

  // Runs on INITIAL RENDER
  useEffect(() => {
    console.log('Fetching initial data...');
    // Fetch from API
  }, []);

  // Runs on INITIAL RENDER and whenever filter changes
  useEffect(() => {
    console.log('Filtering data:', filter);
    // Filter logic here
  }, [filter]);

  // Runs on EVERY render (initial and re-renders)
  useEffect(() => {
    console.log('This runs on every render!');
  });

  return <div>Data: {data}</div>;
}

# useRef

typescript
import { useRef, useEffect } from 'react';

export function RefExample() {
  // On initial render: creates the ref object
  // On re-renders: returns the SAME ref object (identity preserved)
  const countRef = useRef(0);

  useEffect(() => {
    // Increment on every render
    countRef.current += 1;
    console.log('Render count:', countRef.current);
  });

  return <div>Open console to see render count increase</div>;
}

# useMemo

typescript
import { useMemo, useState } from 'react';

export function MemoExample() {
  const [value, setValue] = useState(0);

  // On initial render: computes expensive value
  // On re-renders: returns cached value if deps haven't changed
  const expensiveValue = useMemo(() => {
    console.log('Computing expensive value...');
    return value * 2;
  }, [value]);

  return (
    <div>
      <p>Value: {value}</p>
      <p>Expensive: {expensiveValue}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}

# useEffect: When Does It Actually Run? {#use-effect-timing}

This is where developers get confused. useEffect doesn't run during the render phase—it runs after the component has rendered and the DOM has been updated (or in Concurrent Mode, scheduled to run).

# TypeScript Version

typescript
import { useEffect, useState } from 'react';

export function EffectTimingExample() {
  const [count, setCount] = useState(0);

  // 1. Component function runs (render phase)
  console.log('1. Render phase: component function executing');

  // 2. JSX is evaluated but DOM isn't touched yet
  // 3. React reconciles (compares old and new trees)
  
  // 4. React commits changes to DOM
  
  // 5. THEN useEffect runs (commit phase, after paint in Concurrent Mode)
  useEffect(() => {
    console.log('5. Effect phase: DOM has been updated, useEffect runs');
    console.log('Current count:', count);

    return () => {
      console.log('Cleanup: effect will re-run or component will unmount');
    };
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

# JavaScript Version

javascript
import { useEffect, useState } from 'react';

export function EffectTimingExample() {
  const [count, setCount] = useState(0);

  console.log('1. Render phase: component function executing');

  useEffect(() => {
    console.log('5. Effect phase: DOM has been updated, useEffect runs');
    console.log('Current count:', count);

    return () => {
      console.log('Cleanup: effect will re-run or component will unmount');
    };
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Important timing details:

  1. Render phase - Your component function runs, JSX is evaluated. This phase must be pure (no side effects).
  2. Reconciliation - React diffs the new tree against the old one.
  3. Commit phase - React applies changes to the DOM.
  4. useEffect phase - After DOM updates, useEffect callbacks are scheduled and run (possibly after paint in Concurrent Mode).

# Layout Effects and Timing Guarantees {#layout-effects}

useLayoutEffect is the exception—it runs synchronously after DOM mutations but before the browser paints.

# TypeScript Version

typescript
import { useLayoutEffect, useEffect, useState, useRef } from 'react';

export function LayoutEffectExample() {
  const [color, setColor] = useState('red');
  const divRef = useRef<HTMLDivElement>(null);

  // Runs AFTER DOM mutation but BEFORE browser paint
  useLayoutEffect(() => {
    console.log('useLayoutEffect: DOM has been updated, before paint');
    if (divRef.current) {
      // Safe to measure and adjust layout synchronously
      const width = divRef.current.offsetWidth;
      console.log('Width:', width);
    }

    return () => {
      console.log('useLayoutEffect cleanup');
    };
  }, [color]);

  // Runs AFTER browser has painted
  useEffect(() => {
    console.log('useEffect: browser has painted, now this runs');

    return () => {
      console.log('useEffect cleanup');
    };
  }, [color]);

  return (
    <div
      ref={divRef}
      style={{
        backgroundColor: color,
        padding: '20px',
        marginBottom: '10px',
      }}
    >
      Click to change color
      <button onClick={() => setColor(color === 'red' ? 'blue' : 'red')}>
        Toggle
      </button>
    </div>
  );
}

# JavaScript Version

javascript
import { useLayoutEffect, useEffect, useState, useRef } from 'react';

export function LayoutEffectExample() {
  const [color, setColor] = useState('red');
  const divRef = useRef(null);

  // Runs AFTER DOM mutation but BEFORE browser paint
  useLayoutEffect(() => {
    console.log('useLayoutEffect: DOM has been updated, before paint');
    if (divRef.current) {
      const width = divRef.current.offsetWidth;
      console.log('Width:', width);
    }

    return () => {
      console.log('useLayoutEffect cleanup');
    };
  }, [color]);

  // Runs AFTER browser has painted
  useEffect(() => {
    console.log('useEffect: browser has painted, now this runs');

    return () => {
      console.log('useEffect cleanup');
    };
  }, [color]);

  return (
    <div
      ref={divRef}
      style={{
        backgroundColor: color,
        padding: '20px',
        marginBottom: '10px',
      }}
    >
      Click to change color
      <button onClick={() => setColor(color === 'red' ? 'blue' : 'red')}>
        Toggle
      </button>
    </div>
  );
}

Performance note: useLayoutEffect can block the browser's painting, so use it sparingly. Only use it when you need to read layout information or adjust the DOM synchronously before paint (e.g., positioning tooltips, measuring elements).

# The Render and Commit Phases {#render-commit}

React's component lifecycle has two main phases:

# Render Phase

  • Your component function executes
  • JSX is evaluated
  • Hooks are called
  • Must be pure—no side effects allowed
  • Can be paused, aborted, or re-run (in Concurrent Mode)

# Commit Phase

  • React applies changes to the DOM
  • useLayoutEffect runs synchronously
  • Browser paints
  • useEffect runs (possibly asynchronously in Concurrent Mode)
  • Event listeners are attached

# TypeScript Version: Phase Awareness

typescript
import { useEffect, useLayoutEffect, useState } from 'react';

export function PhaseAwareComponent() {
  const [value, setValue] = useState(0);

  // ❌ WRONG: Impure render phase
  // fetch('https://api.example.com/data');  // Don't do this!

  // ❌ WRONG: Side effect in render phase
  // document.title = `Count: ${value}`;  // Don't do this!

  // ✅ CORRECT: Effects handle side effects
  useEffect(() => {
    // Safe to fetch, update DOM, subscribe to events, etc.
    document.title = `Count: ${value}`;
  }, [value]);

  // ✅ CORRECT: For layout measurements
  useLayoutEffect(() => {
    // Safe to read DOM measurements synchronously
    console.log('Window width:', window.innerWidth);
  }, []);

  // ✅ CORRECT: Render phase is safe for hooks and pure computation
  const doubledValue = value * 2;  // Pure computation is fine

  return (
    <div>
      <p>Value: {value}</p>
      <p>Doubled: {doubledValue}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}

# JavaScript Version

javascript
import { useEffect, useLayoutEffect, useState } from 'react';

export function PhaseAwareComponent() {
  const [value, setValue] = useState(0);

  // ✅ CORRECT: Effects handle side effects
  useEffect(() => {
    document.title = `Count: ${value}`;
  }, [value]);

  // ✅ CORRECT: For layout measurements
  useLayoutEffect(() => {
    console.log('Window width:', window.innerWidth);
  }, []);

  const doubledValue = value * 2;

  return (
    <div>
      <p>Value: {value}</p>
      <p>Doubled: {doubledValue}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}

# Conditional Logic Based on Phase {#conditional-logic}

You can detect whether you're in an initial render or re-render and branch your logic accordingly.

# TypeScript Version

typescript
import { useEffect, useRef, useState, ReactNode } from 'react';

interface ConditionalLogicProps {
  onInitialMount?: () => void;
  children?: ReactNode;
}

export function ConditionalLogic({ 
  onInitialMount, 
  children 
}: ConditionalLogicProps) {
  const [count, setCount] = useState(0);
  const isInitialRenderRef = useRef(true);

  // Option 1: Run effect only on initial mount
  useEffect(() => {
    console.log('Running setup for initial render');
    if (onInitialMount) onInitialMount();

    // This cleanup runs on unmount (not on every re-render)
    return () => {
      console.log('Running cleanup on unmount');
    };
  }, [onInitialMount]); // Empty deps = initial mount only

  // Option 2: Detect phase and run different logic
  useEffect(() => {
    if (isInitialRenderRef.current) {
      console.log('Initial render logic');
      isInitialRenderRef.current = false;
    } else {
      console.log('Re-render logic, count changed to:', count);
    }
  }, [count]);

  // Option 3: Initialize complex state in useState
  const [data, setData] = useState(() => {
    console.log('Expensive initial computation');
    return { initialized: true, timestamp: Date.now() };
  });

  return (
    <div>
      <p>Count: {count}</p>
      <p>Data: {JSON.stringify(data)}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      {children}
    </div>
  );
}

# JavaScript Version

javascript
import { useEffect, useRef, useState } from 'react';

export function ConditionalLogic({ onInitialMount, children }) {
  const [count, setCount] = useState(0);
  const isInitialRenderRef = useRef(true);

  // Option 1: Run effect only on initial mount
  useEffect(() => {
    console.log('Running setup for initial render');
    if (onInitialMount) onInitialMount();

    return () => {
      console.log('Running cleanup on unmount');
    };
  }, [onInitialMount]);

  // Option 2: Detect phase and run different logic
  useEffect(() => {
    if (isInitialRenderRef.current) {
      console.log('Initial render logic');
      isInitialRenderRef.current = false;
    } else {
      console.log('Re-render logic, count changed to:', count);
    }
  }, [count]);

  // Option 3: Initialize complex state
  const [data, setData] = useState(() => {
    console.log('Expensive initial computation');
    return { initialized: true, timestamp: Date.now() };
  });

  return (
    <div>
      <p>Count: {count}</p>
      <p>Data: {JSON.stringify(data)}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      {children}
    </div>
  );
}

# Real-World Patterns and Gotchas {#patterns-gotchas}

# Pattern 1: Data Fetching on Initial Render

typescript
import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
}

interface UserProfileProps {
  userId: number;
}

export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  // Fetch data on initial render AND when userId changes
  useEffect(() => {
    let isMounted = true;

    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();

        // Only update state if component is still mounted
        if (isMounted) {
          setUser(data);
          setError(null);
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
          setUser(null);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    // Cleanup: prevent state updates if component unmounts
    return () => {
      isMounted = false;
    };
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>No user found</div>;

  return <div>User: {user.name}</div>;
}

# Pattern 2: Subscribe on Initial Render, Unsubscribe on Unmount

typescript
import { useEffect, useState } from 'react';

export function WindowSizeTracker() {
  const [windowSize, setWindowSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  });

  useEffect(() => {
    // Subscribe on initial render
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener('resize', handleResize);

    // Unsubscribe on unmount
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty deps = subscribe once on mount

  return (
    <div>
      Width: {windowSize.width}, Height: {windowSize.height}
    </div>
  );
}

# Gotcha: Stale Closures

typescript
import { useState, useEffect } from 'react';

export function StaleClosureExample() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // ❌ This closure captures count from when effect was created
    const handleClick = () => {
      console.log('Count is:', count);  // Logs old value!
    };

    const button = document.querySelector('button');
    if (button) {
      button.addEventListener('click', handleClick);
    }

    return () => {
      if (button) {
        button.removeEventListener('click', handleClick);
      }
    };
  }, [count]); // ⚠️ Adding count as dependency recreates this effect

  return <button>Count: {count}</button>;
}

# Performance Implications {#performance}

# Initial Render is More Expensive

Initial renders include creating DOM nodes, which is more expensive than updating existing ones. However, you only do this once per component.

# Re-Renders Should Be Cheap

Re-renders happen frequently (sometimes dozens of times per user interaction). Optimize them aggressively:

typescript
import { memo, useCallback, useMemo } from 'react';

interface OptimizedChildProps {
  value: number;
  onUpdate: (val: number) => void;
}

// Memoize to prevent unnecessary re-renders
const OptimizedChild = memo(function OptimizedChild({ 
  value, 
  onUpdate 
}: OptimizedChildProps) {
  console.log('Child rendered');
  return (
    <div>
      Value: {value}
      <button onClick={() => onUpdate(value + 1)}>Update</button>
    </div>
  );
});

export function Parent() {
  const [parentCount, setParentCount] = useState(0);
  const [childValue, setChildValue] = useState(0);

  // Memoize callback to keep reference stable
  const handleChildUpdate = useCallback((val: number) => {
    setChildValue(val);
  }, []);

  return (
    <div>
      <p>Parent: {parentCount}</p>
      <button onClick={() => setParentCount(parentCount + 1)}>
        Parent Update
      </button>
      
      {/* Memoized child only re-renders when its props actually change */}
      <OptimizedChild value={childValue} onUpdate={handleChildUpdate} />
    </div>
  );
}

# Practical Application: Building a data-fetching component with proper phase awareness

Let's build a real-world component that demonstrates the distinction between initial render and re-render.

# TypeScript Version

typescript
import { useEffect, useRef, useState, ReactNode } from 'react';

interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

interface CachedDataFetcherProps<T> {
  url: string;
  cacheTime?: number;
  onInitialLoad?: () => void;
  onReload?: () => void;
  children: (data: T | null, isLoading: boolean, error: Error | null) => ReactNode;
}

export function CachedDataFetcher<T>({
  url,
  cacheTime = 5 * 60 * 1000, // 5 minutes
  onInitialLoad,
  onReload,
  children,
}: CachedDataFetcherProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  const cacheRef = useRef<Map<string, CacheEntry<T>>>(new Map());
  const isInitialRenderRef = useRef(true);

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      // Check cache first
      const cached = cacheRef.current.get(url);
      if (cached && Date.now() - cached.timestamp < cacheTime) {
        setData(cached.data);
        setLoading(false);
        return;
      }

      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();

        if (isMounted) {
          setData(result);
          setError(null);

          // Cache the result
          cacheRef.current.set(url, {
            data: result,
            timestamp: Date.now(),
          });

          // Call appropriate callback based on phase
          if (isInitialRenderRef.current) {
            onInitialLoad?.();
          } else {
            onReload?.();
          }
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
          setData(null);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
          isInitialRenderRef.current = false;
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url, cacheTime, onInitialLoad, onReload]);

  return <>{children(data, loading, error)}</>;
}

// Usage example
export function App() {
  return (
    <CachedDataFetcher
      url="https://jsonplaceholder.typicode.com/users/1"
      onInitialLoad={() => console.log('Initial data loaded')}
      onReload={() => console.log('Data reloaded')}
    >
      {(data, loading, error) => (
        <>
          {loading && <p>Loading...</p>}
          {error && <p>Error: {error.message}</p>}
          {data && <p>User: {JSON.stringify(data)}</p>}
        </>
      )}
    </CachedDataFetcher>
  );
}

# JavaScript Version

javascript
import { useEffect, useRef, useState } from 'react';

export function CachedDataFetcher({
  url,
  cacheTime = 5 * 60 * 1000,
  onInitialLoad,
  onReload,
  children,
}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const cacheRef = useRef(new Map());
  const isInitialRenderRef = useRef(true);

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      const cached = cacheRef.current.get(url);
      if (cached && Date.now() - cached.timestamp < cacheTime) {
        setData(cached.data);
        setLoading(false);
        return;
      }

      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();

        if (isMounted) {
          setData(result);
          setError(null);

          cacheRef.current.set(url, {
            data: result,
            timestamp: Date.now(),
          });

          if (isInitialRenderRef.current) {
            onInitialLoad?.();
          } else {
            onReload?.();
          }
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
          setData(null);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
          isInitialRenderRef.current = false;
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url, cacheTime, onInitialLoad, onReload]);

  return <>{children(data, loading, error)}</>;
}

export function App() {
  return (
    <CachedDataFetcher
      url="https://jsonplaceholder.typicode.com/users/1"
      onInitialLoad={() => console.log('Initial data loaded')}
      onReload={() => console.log('Data reloaded')}
    >
      {(data, loading, error) => (
        <>
          {loading && <p>Loading...</p>}
          {error && <p>Error: {error.message}</p>}
          {data && <p>User: {JSON.stringify(data)}</p>}
        </>
      )}
    </CachedDataFetcher>
  );
}

Performance note: This component demonstrates several phase-aware optimizations: cache detection before fetching, separate callbacks for initial vs. reload, and cleanup to prevent state updates after unmount. The key difference is that on the initial render, we want to log different analytics; on re-renders, we might want different behavior entirely.

# FAQ

# Q: Can I detect initial render without using a ref?

A: Not reliably without refs. The cleanest patterns are:

  • Use useEffect with empty dependencies for initial-only effects
  • Use a ref to track phase across renders
  • Use custom hooks that encapsulate this logic

The ref approach is standard because refs maintain identity across renders, making them ideal for tracking phase.

# Q: Why does my useEffect run twice on initial render?

A: In React 18+ with Strict Mode (development only), React intentionally runs effects twice to help detect side effects that aren't properly cleaned up. This is a development-only feature. Remove <StrictMode> to see single execution, or just ensure your effects are idempotent.

# Q: What's the difference between mount and initial render?

A: "Mount" refers to when a component first appears in the DOM. "Initial render" is the rendering phase that precedes mounting. In React's lifecycle, the initial render happens first, then mounting (commit phase), then effects run.

# Q: Should I use useLayoutEffect for data fetching?

A: No. useLayoutEffect blocks painting and should only be used for layout measurements or DOM adjustments. Use useEffect for data fetching. The only exception is if you need to measure the DOM synchronously before the browser paints.

# Q: Can the initial render and re-renders have different props?

A: No, props don't change during a single render cycle. However, props CAN change between initial render and subsequent re-renders. When props change, a re-render is triggered. This is why useEffect dependency arrays include props—when props change, the effect might need to run again.

# Q: How do I prevent useEffect from running on initial render?

A: Track with a ref or use a custom hook:

typescript
function useUpdateEffect(effect, deps) {
  const isInitialRender = useRef(true);

  useEffect(() => {
    if (isInitialRender.current) {
      isInitialRender.current = false;
      return;
    }
    
    return effect();
  }, deps);
}

Still puzzled by render phases? Drop your questions in the comments. Understanding initial vs. re-render is one of those concepts that clicks once you see real examples—let's discuss what confuses you most!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT