Rohit Nandi

Full Stack Engineer

useUpdateEffect Hook
useUpdateEffect Hook (Published on 25th Oct, 2025)

The Problem: When useEffect Runs Too Early

Picture this: You're building a form component that needs to validate user input and show error messages. You want to run validation whenever the input changes, but not when the component first mounts with empty values. This is a common scenario where the standard useEffect hook falls short.

function ContactForm() {
  const [email, setEmail] = useState('')
  const [errors, setErrors] = useState({})

  // This runs on mount too - not what we want!
  useEffect(() => {
    if (email && !isValidEmail(email)) {
      setErrors((prev) => ({ ...prev, email: 'Invalid email format' }))
    }
  }, [email])

  return (
    <form>
      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Enter your email"
      />
      {errors.email && <span className="error">{errors.email}</span>}
    </form>
  )
}

The issue here is that useEffect runs on every render, including the initial mount. In our form example, this means validation runs immediately when the component mounts, potentially showing error messages before the user has even started typing.

This is where useUpdateEffect comes in - it's a custom hook that behaves exactly like useEffect, but with one crucial difference: it skips running the callback on the first render.

What is useUpdateEffect?

useUpdateEffect is a custom React hook that provides the same functionality as useEffect, but it only runs on subsequent renders, not on the initial mount. This makes it perfect for scenarios where you want to respond to changes but not to the initial state.

The hook signature is identical to useEffect:

useUpdateEffect(effect: EffectCallback, deps?: DependencyList)

Where:

  • effect: The function to run when dependencies change
  • deps: Optional dependency array (same as useEffect)

Implementation Approach 1: Using useEffect with a Ref

The most straightforward approach uses useEffect internally with a ref to track whether this is the first render:

import { useEffect, useRef, EffectCallback, DependencyList } from 'react'

export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList) {
  const isFirstRender = useRef(true)

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false
      return
    }
    return effect()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)
}

How It Works

  1. First Render: The isFirstRender ref starts as true
  2. Effect Runs: The effect function runs, but immediately returns without calling the user's callback
  3. Ref Updated: The ref is set to false for subsequent renders
  4. Subsequent Renders: The effect runs normally, calling the user's callback

The ESLint disable comment is necessary because we're intentionally not including effect in the dependency array. This is safe because the effect function is provided by the user and should be stable.

Implementation Approach 2: Manual Dependency Tracking

The second approach completely bypasses useEffect and manually tracks dependencies:

import React, { EffectCallback, DependencyList, useRef } from 'react'

export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList) {
  const prevDeps = useRef<DependencyList>()
  const cleanUpFunctionRef = useRef<Function | null>(null)

  const prevDepsString = JSON.stringify(prevDeps.current)
  const currentDepsString = JSON.stringify(deps)

  if (prevDepsString !== currentDepsString && prevDeps.current) {
    cleanUpFunctionRef.current?.()
    const callback = effect()
    if (callback) {
      cleanUpFunctionRef.current = callback
    }
  }

  prevDeps.current = deps
}

How It Works

  1. Dependency Comparison: Uses JSON.stringify to compare previous and current dependencies
  2. Skip First Render: Only runs the effect if prevDeps.current exists (not on first render)
  3. Manual Cleanup: Manually handles cleanup functions by storing them in a ref
  4. Update Tracking: Updates the previous dependencies for the next comparison

1. Form Validation

The most common use case is form validation that shouldn't run on initial mount:

function UserProfile() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    phone: '',
  })
  const [errors, setErrors] = useState({})

  // Only validate after user starts interacting
  useUpdateEffect(() => {
    const newErrors = {}

    if (formData.name && formData.name.length < 2) {
      newErrors.name = 'Name must be at least 2 characters'
    }

    if (formData.email && !isValidEmail(formData.email)) {
      newErrors.email = 'Please enter a valid email'
    }

    if (formData.phone && !isValidPhone(formData.phone)) {
      newErrors.phone = 'Please enter a valid phone number'
    }

    setErrors(newErrors)
  }, [formData])

  return (
    <form>
      <input
        value={formData.name}
        onChange={(e) =>
          setFormData((prev) => ({ ...prev, name: e.target.value }))
        }
        placeholder="Full Name"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      {/* Similar for email and phone */}
    </form>
  )
}

2. API Calls on State Changes

Sometimes you want to fetch data when certain state changes, but not on the initial load:

function ProductSearch({ initialQuery = '' }) {
  const [query, setQuery] = useState(initialQuery)
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

  // Only search when query changes, not on initial mount
  useUpdateEffect(() => {
    if (query.trim()) {
      setLoading(true)
      searchProducts(query)
        .then(setResults)
        .finally(() => setLoading(false))
    }
  }, [query])

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
      />
      {loading && <div>Searching...</div>}
      {results.map((product) => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  )
}

3. Analytics Tracking

Track user interactions without firing events on page load:

function ProductPage({ productId }) {
  const [viewTime, setViewTime] = useState(0)
  const [hasScrolled, setHasScrolled] = useState(false)

  // Track scroll events, but not on initial mount
  useUpdateEffect(() => {
    if (hasScrolled) {
      analytics.track('product_page_scrolled', {
        productId,
        viewTime,
      })
    }
  }, [hasScrolled, viewTime, productId])

  // Track time spent on page, but not on initial mount
  useUpdateEffect(() => {
    const interval = setInterval(() => {
      setViewTime((prev) => prev + 1)
    }, 1000)

    return () => clearInterval(interval)
  }, []) // Empty deps - runs once after first render

  return <div>Product content...</div>
}

4. URL Synchronization

Keep URL in sync with state changes, but not on initial load:

function FilterableProductList() {
  const [filters, setFilters] = useState({
    category: '',
    priceRange: '',
    sortBy: 'name',
  })

  // Update URL when filters change, but not on initial mount
  useUpdateEffect(() => {
    const searchParams = new URLSearchParams()

    Object.entries(filters).forEach(([key, value]) => {
      if (value) {
        searchParams.set(key, value)
      }
    })

    const newUrl = `${window.location.pathname}?${searchParams.toString()}`
    window.history.pushState({}, '', newUrl)
  }, [filters])

  return (
    <div>
      {/* Filter controls */}
      <select
        value={filters.category}
        onChange={(e) =>
          setFilters((prev) => ({ ...prev, category: e.target.value }))
        }
      >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>

      {/* Product list */}
    </div>
  )
}

Advanced Patterns

1. Conditional useUpdateEffect

Sometimes you want the effect to run only under certain conditions:

function DataFetcher({ shouldFetch, endpoint }) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(false)

  useUpdateEffect(() => {
    if (shouldFetch && endpoint) {
      setLoading(true)
      fetch(endpoint)
        .then((response) => response.json())
        .then(setData)
        .finally(() => setLoading(false))
    }
  }, [shouldFetch, endpoint])

  return loading ? <div>Loading...</div> : <div>{JSON.stringify(data)}</div>
}

2. Debounced useUpdateEffect

For expensive operations, you might want to debounce the effect:

function useDebouncedUpdateEffect(effect, deps, delay = 300) {
  const timeoutRef = useRef()

  useUpdateEffect(() => {
    clearTimeout(timeoutRef.current)
    timeoutRef.current = setTimeout(effect, delay)

    return () => clearTimeout(timeoutRef.current)
  }, deps)
}

// Usage
function SearchInput() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])

  useDebouncedUpdateEffect(
    () => {
      if (query) {
        searchAPI(query).then(setResults)
      }
    },
    [query],
    500,
  ) // 500ms debounce

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {results.map((result) => (
        <div key={result.id}>{result.title}</div>
      ))}
    </div>
  )
}

Performance Tips

  1. Minimize Dependencies: Only include values that actually affect the effect
  2. Use useCallback: If your effect function is complex, wrap it in useCallback
  3. Consider useMemo: For expensive computations in the effect
  4. Debounce When Needed: For effects that might run too frequently
function OptimizedComponent({ data, filters }) {
  // Memoize expensive computation
  const processedData = useMemo(() => {
    return data.filter((item) =>
      filters.category ? item.category === filters.category : true,
    )
  }, [data, filters.category])

  // Use useCallback for stable reference
  const handleDataChange = useCallback(() => {
    // Effect logic here
  }, [processedData])

  useUpdateEffect(handleDataChange, [processedData])

  return <div>{/* Component JSX */}</div>
}

Conclusion

useUpdateEffect is a powerful tool for handling side effects that should only run after the initial render. It's particularly useful for:

  • Form validation that shouldn't run on mount
  • API calls triggered by state changes
  • Analytics tracking for user behavior
  • URL synchronization
  • Any effect that depends on user interaction

The two implementation approaches each have their strengths:

  • useEffect-based approach: Simpler, follows React patterns, handles cleanup automatically
  • Manual tracking approach: More performant, no effect overhead on first render

Choose the implementation that best fits your needs. For most use cases, the useEffect-based approach is recommended due to its simplicity and alignment with React's mental model.

Remember to always handle cleanup functions, be mindful of dependency arrays, and test your custom hooks thoroughly. With proper usage, useUpdateEffect can make your components more predictable and user-friendly.

The key insight is that sometimes you want to respond to changes, but not to the initial state. useUpdateEffect gives you that control, making it an essential tool in any React developer's toolkit.

© 2025 Rohit