React Hooks Guide: useEffect, useCallback, useRef, useMemo - Cloud Full-stack

React Hooks Tutorial: useEffect, useCallback, useRef, useMemo Guide 2025

When I first started learning React, I came from an Angular background — and honestly, I was really confused. React felt very different, and I especially struggled to understand how React Hooks work. Things like useEffect and useCallback were hard to grasp at first.

That’s why I created this guide — to make it easier for you.

I’ll explain React Hooks using simple examples, real output logs, and step-by-step code — not just dry theory. You’ll learn how each hook works, what happens behind the scenes, and why they behave that way.

This guide also includes common interview questions about hooks that people often get asked — so you’ll be more prepared for technical interviews too.

Understanding useEffect

Let’s start with useEffect, which is probably the most important hook you’ll use after useState.

What is useEffect?

useEffect is used for handling side effects in the components. Side effects are things like fetching data, updating the DOM, setting up subscriptions, or cleaning up resources. Basically, anything that happens “outside” of just rendering your component.

Basic Pattern

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

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

  useEffect(() => {
    // This runs after every render
    document.title = `Count: ${count}`;
  });

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

When React renders this component, it remembers the useEffect function you gave it. After the DOM is updated and painted to the screen, React calls your effect function.

So the sequence is: render update DOMrun effects.

Every time the component re-renders (like when count changes), React will run this effect again. That’s why the document title updates every time you click the button.

useEffect with Dependencies

Running effects after every render can be expensive. Let’s say you’re fetching data from an API – you don’t want to do that on every single render! This is where the dependency array comes in.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // This only runs when userId changes
    async function fetchUser() {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        setUser(userData);
      } catch (error) {
        console.error('Failed to fetch user:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchUser();
  }, [userId]); // This is the dependency array

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

React compares the current dependency array [userId] with the previous one. If userId hasn’t changed, React skips running the effect. If it has changed, React runs the effect. This is called “shallow comparison” – React checks if the values are the same using ===.

The Three Types of useEffect

1. Effect with no dependencies (runs after every render)

useEffect(() => {
  console.log('This runs after every render');
});

2. Effect with dependencies (runs only when dependencies change)

useEffect(() => {
  console.log('This runs only when count changes');
}, [count]);

3. Effect with empty dependencies (runs only once after mount)

useEffect(() => {
  console.log('This runs only once after component mounts');
}, []);

Preventing Memory Leaks

Sometimes your effects need to clean up after themselves. For example, if you set up a timer or subscribe to events, you need to clean them up when the component unmounts or before the effect runs again.

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // Cleanup function
    return () => {
      clearInterval(interval);
    };
  }, []);

  return <div>Timer: {seconds} seconds</div>;
}

When you return a function from useEffect, React treats it as a cleanup function. React will call this cleanup function:

  • Before running the effect again (if dependencies changed)
  • When the component unmounts

This prevents memory leaks and ensures your app doesn’t keep running timers or maintaining subscriptions for components that no longer exist.

#1: Multiple useEffect Execution Order

Look at this code. When the component first mounts, what will be printed in the console? Then what happens when you click the button?

function TrickyComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');

  console.log('1. Component render start');

  useEffect(() => {
    console.log('2. Effect A runs - no dependencies');
  });

  useEffect(() => {
    console.log('3. Effect B runs - count dependency:', count);
  }, [count]);

  useEffect(() => {
    console.log('4. Effect C runs - empty dependency');
  }, []);

  useEffect(() => {
    console.log('5. Effect D runs - name dependency:', name);
  }, [name]);

  console.log('6. Component render end');

  return (
    <div>
      <p>Count: {count}</p>
      <p>Name: {name}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment Count
      </button>
      <button onClick={() => setName('Jane')}>
        Change Name
      </button>
    </div>
  );
}

When component first mounts:

1. Component render start
6. Component render end
2. Effect A runs - no dependencies
3. Effect B runs - count dependency: 0
4. Effect C runs - empty dependency
5. Effect D runs - name dependency: John

When you click “Increment Count” button:

1. Component render start
6. Component render end
2. Effect A runs - no dependencies
3. Effect B runs - count dependency: 1

When you click “Change Name” button:

1. Component render start
6. Component render end
2. Effect A runs - no dependencies
5. Effect D runs - name dependency: Jane

React runs effects in the order they appear in your code, but only runs them if their dependencies have changed. Effect A runs every time because it has no dependency array. Effect C only runs once because it has an empty dependency array.

#2. Cleanup Functions with Console Logs

What will be printed when this component mounts, when count changes, and when it unmounts?

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

  console.log('Component rendering with count:', count);

  useEffect(() => {
    console.log('Effect 1: Setting up with count:', count);
    
    return () => {
      console.log('Effect 1: Cleaning up with count:', count);
    };
  }, [count]);

  useEffect(() => {
    console.log('Effect 2: Setting up timer');
    
    const timer = setInterval(() => {
      console.log('Timer tick');
    }, 1000);

    return () => {
      console.log('Effect 2: Cleaning up timer');
      clearInterval(timer);
    };
  }, []);

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

When component first mounts:

Component rendering with count: 0
Effect 1: Setting up with count: 0
Effect 2: Setting up timer
Timer tick (every second)

When you click button (count becomes 1):

Component rendering with count: 1
Effect 1: Cleaning up with count: 0
Effect 1: Setting up with count: 1
Timer tick (continues)

When component unmounts:

Effect 1: Cleaning up with count: 1
Effect 2: Cleaning up timer

The cleanup function always has access to the values from when the effect was created. That’s why “Cleaning up with count: 0” shows 0, not 1. React runs cleanup before running the effect again.

#3. What’s the execution order when this component mounts and when userId changes?

function ComplexComponent({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);

  console.log('Component rendering with userId:', userId);

  useEffect(() => {
    console.log('Effect 1: userId changed to', userId);
    setUser(null);
    setPosts([]);
    setLoading(true);
  }, [userId]);

  useEffect(() => {
    if (!userId) {
      console.log('Effect 2: No userId, skipping user fetch');
      return;
    }

    console.log('Effect 2: Fetching user for', userId);
    
    // Simulate API call
    setTimeout(() => {
      console.log('Effect 2: User fetched for', userId);
      setUser({ id: userId, name: `User ${userId}` });
    }, 1000);
  }, [userId]);

  useEffect(() => {
    if (!user) {
      console.log('Effect 3: No user, skipping posts fetch');
      return;
    }

    console.log('Effect 3: Fetching posts for user', user.id);
    
    // Simulate API call
    setTimeout(() => {
      console.log('Effect 3: Posts fetched for user', user.id);
      setPosts([{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }]);
      setLoading(false);
    }, 1000);
  }, [user]);

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h2>{user?.name}</h2>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

When component mounts with userId=123:

Component rendering with userId: 123
Effect 1: userId changed to 123
Effect 2: Fetching user for 123
Effect 3: No user, skipping posts fetch

// Component re-render after setUser, setPost, setLoading
Component rendering with userId: 123

// After 1 second
Effect 2: User fetched for 123
Component rendering with userId: 123 // After setUser
Effect 3: Fetching posts for user 123

// After another 1 second:
Effect 3: Posts fetched for user 123
Component rendering with userId: 123  // After setPosts() and setLoading(false)

When userId changes from 123 to 456:

Component rendering with userId: 456
Effect 1: userId changed to 456
Effect 2: Fetching user for 456
Effect 3: No user, skipping posts fetch
Component rendering with userId: 456

// After 1 second:
Effect 2: User fetched for 456
Component rendering with userId: 456
Effect 3: Fetching posts for user 456

// After another 1 second:
Effect 3: Posts fetched for user 456
Component rendering with userId: 456

Key Takeaways

  • Order matters: Effects run in declaration order, and async updates (like setTimeout) resolve later, triggering cascading renders.
  • State updates in effects are batched and cause re-renders after all effects finish executing in a cycle.
  • Effects only re-run if dependencies change between renders.

Understanding useCallback

Now let’s talk about useCallback, which is all about performance optimization.

The Problem useCallback Solves

In JavaScript, every time a function is created, it’s a new object in memory. This can cause unnecessary re-renders in React when you pass functions as props to child components.

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  // This creates a new function every render
  const handleClick = () => {
    console.log('Button clicked!');
  };

  return (
    <div>
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
      />
      <ExpensiveChild onClick={handleClick} />
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

const ExpensiveChild = React.memo(({ onClick }) => {
  console.log('ExpensiveChild rendered');
  return <button onClick={onClick}>Click me</button>;
});

Even though we wrapped ExpensiveChild with React.memo, it still re-renders every time the parent renders. This is because handleClick is a new function every time, so React thinks the props have changed.

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  const handleClick = useCallback(() => {
    console.log('Button clicked!');
  }, []);

  return (
    <div>
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
      />
      <ExpensiveChild onClick={handleClick} />
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

useCallback returns a memoized version of the function. React keeps the same function reference across renders unless the dependencies change. Now ExpensiveChild won’t re-render when you type in the input or increment the count.

useCallback with Dependencies

Sometimes your callback needs to use values from the component scope:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const searchItems = useCallback(async (searchTerm) => {
    if (!searchTerm) {
      setResults([]);
      return;
    }

    try {
      const response = await fetch(`/api/search?q=${searchTerm}`);
      const data = await response.json();
      setResults(data);
    } catch (error) {
      console.error('Search failed:', error);
    }
  }, []); // No dependencies needed since we pass searchTerm as parameter

  const handleSearch = useCallback(() => {
    searchItems(query);
  }, [query, searchItems]); // Depends on query and searchItems

  return (
    <div>
      <input 
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <SearchButton onClick={handleSearch} />
      <SearchResults results={results} />
    </div>
  );
}

If you don’t use useCallback, then handleSearch and searchItems will be new function instances on every render.

When NOT to Use useCallback

Here’s an important lesson: don’t use useCallback everywhere! It’s only useful when:

  1. You’re passing the function to a child component wrapped in React.memo
  2. The function is used as a dependency in useEffect or other hooks
  3. The function is expensive to create
// DON'T do this - unnecessary optimization
function SimpleComponent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]); // This actually makes things worse!

  return (
    <button onClick={handleClick}>
      Count: {count}
    </button>
  );
}

// DO this instead - simple and clean
function SimpleComponent() {
  const [count, setCount] = useState(0);

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

In first example, we’re trying to “optimize” by memoizing handleClick — but it’s actually being re-created on every render anyway because:

  • count changes every time you click
  • handleClick depends on count
  • So the function is re-created every time
  • You gain nothing, but add complexity

#4. Why does this child component keep re-rendering even though we used React.memo?

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');

  console.log('Parent rendering');

  const handleClick = useCallback(() => {
    console.log('Button clicked with count:', count);
    alert(`Count is ${count}`);
  }, []); // Wrong dependency array!

  return (
    <div>
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
      />
      <ChildComponent onClick={handleClick} />
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
    </div>
  );
}

const ChildComponent = React.memo(({ onClick }) => {
  console.log('Child rendering');
  return <button onClick={onClick}>Click me</button>;
});

What happens when you click the count button:

Parent rendering
Child rendering  // Child re-renders because handleClick reference changes

The problem: Even though we used useCallback, the function still changes because count is used inside but not in the dependency array.

const handleClick = useCallback(() => {
  console.log('Button clicked with count:', count);
  alert(`Count is ${count}`);
}, [count]); // Include count in dependencies

Understanding useMemo

useMemo is useCallback‘s cousin, but instead of memoizing functions, it memoizes values.

The Problem useMemo Solves

Sometimes we have expensive calculations that we don’t want to run on every render:

function ExpensiveComponent({ items }) {
  const [filter, setFilter] = useState('');

  // This expensive calculation runs on every render
  const expensiveValue = items
    .filter(item => item.category === 'electronics')
    .reduce((sum, item) => sum + item.price, 0);

  const filteredItems = items.filter(item => 
    item.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input 
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter items..."
      />
      <p>Total electronics value: ${expensiveValue}</p>
      <div>
        {filteredItems.map(item => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    </div>
  );
}

Every time we type in the filter input, the component re-renders, and the expensive calculation runs again even though the items haven’t changed.

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

function ExpensiveComponent({ items }) {
  const [filter, setFilter] = useState('');

  // This expensive calculation only runs when items change
  const expensiveValue = useMemo(() => {
    console.log('Calculating expensive value...');
    return items
      .filter(item => item.category === 'electronics')
      .reduce((sum, item) => sum + item.price, 0);
  }, [items]);

  // This filtered list only recalculates when items or filter changes
  const filteredItems = useMemo(() => {
    console.log('Filtering items...');
    return items.filter(item => 
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [items, filter]);

  return (
    <div>
      <input 
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter items..."
      />
      <p>Total electronics value: ${expensiveValue}</p>
      <div>
        {filteredItems.map(item => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    </div>
  );
}

In this fix, useMemo runs the function and caches the result. On subsequent renders, if the dependencies haven’t changed, it returns the cached value instead of running the function again.

Understanding useRef

useRef is unique because it doesn’t trigger re-renders when its value changes. When you use useRef, React gives you a special object like this:

const myRef = useRef(initialValue);

This object looks like:

{ current: initialValue }

You can read or update the .current value anytime, and React will not re-render the component. That’s the key.

Accessing DOM Elements

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

function FocusInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    // Focus the input when component mounts
    inputRef.current.focus();
  }, []);

  const handleFocus = () => {
    inputRef.current.focus();
  };

  return (
    <div>
      <input 
        ref={inputRef}
        type="text"
        placeholder="This will be focused on mount"
      />
      <button onClick={handleFocus}>Focus Input</button>
    </div>
  );
}

In FocusInput function, we use useRef to grab the actual DOM input element. When the component loads, we call:

inputRef.current.focus();

This is just like saying: “Hey browser, find that input box and focus on it.” This is something you can’t easily do with useState, because state is for data, not for DOM nodes.

Persisting Values Between Renders

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null);

  useEffect(() => {
    if (isRunning) {
      intervalRef.current = setInterval(() => {
        setSeconds(prev => prev + 1);
      }, 1000);
    } else {
      clearInterval(intervalRef.current);
    }

    return () => clearInterval(intervalRef.current);
  }, [isRunning]);

  const reset = () => {
    setSeconds(0);
    setIsRunning(false);
  };

  return (
    <div>
      <p>Time: {seconds}s</p>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Pause' : 'Start'}
      </button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

In Timer function, we store the setInterval ID in intervalRef.current. Why?

Because:

  • We don’t want to re-render every time the timer ID changes.
  • But we still need to remember it between renders so we can stop the timer later using clearInterval().

So useRef is like a “box” where you can store anything — numbers, objects, DOM elements, timers, etc. — and it won’t cause re-renders like useState does.

What if we never use useRef:

let intervalId;

useEffect(() => {
  if (isRunning) {
    intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
  } else {
    clearInterval(intervalId);
  }

  return () => clearInterval(intervalId);
}, [isRunning]);

This will not work reliably because:

  • intervalId is re-declared on every render, so it gets reset to undefined.
  • clearInterval(intervalId) might not stop the correct timer.
  • You risk memory leaks, or multiple timers running at once.

#6. Why You Shouldn’t Use useState for intervalId

const [intervalId, setIntervalId] = useState(null);

useEffect(() => {
  if (isRunning) {
    const id = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
    setIntervalId(id); // 👈 store intervalId in state
  } else {
    clearInterval(intervalId); // ⚠️ might not have the latest ID here!
  }

  return () => clearInterval(intervalId);
}, [isRunning]);

React doesn’t immediately update state. setIntervalId(id) will update it in the next render, but not right away.

That means:

  • When you try to call clearInterval(intervalId), the intervalId might still be null or an old value.
  • This makes cleanup unreliable.
  • In contrast, useRef updates immediately and doesn’t wait for a re-render.

#6. What’s the difference in behavior between these two buttons?

function RefVsStateComponent() {
  const [stateCount, setStateCount] = useState(0);
  const refCount = useRef(0);

  console.log('Component rendering. stateCount:', stateCount, 'refCount:', refCount.current);

  const handleStateIncrement = () => {
    console.log('State button clicked. Before:', stateCount);
    setStateCount(stateCount + 1);
    console.log('State button clicked. After setState:', stateCount); // Still old value!
  };

  const handleRefIncrement = () => {
    console.log('Ref button clicked. Before:', refCount.current);
    refCount.current = refCount.current + 1;
    console.log('Ref button clicked. After:', refCount.current);
  };

  return (
    <div>
      <p>State Count: {stateCount}</p>
      <p>Ref Count: {refCount.current}</p>
      <button onClick={handleStateIncrement}>
        Increment State
      </button>
      <button onClick={handleRefIncrement}>
        Increment Ref
      </button>
    </div>
  );
}

When you click “Increment State”:

State button clicked. Before: 0
State button clicked. After setState: 0  // State updates are asynchronous!
Component rendering. stateCount: 1 refCount: 0

When you click “Increment Ref”:

Ref button clicked. Before: 0
Ref button clicked. After: 1
// No re-render happens!

useState triggers re-renders and updates are asynchronous. useRef doesn’t trigger re-renders and updates are immediate. The display won’t update for ref changes until something else causes a re-render.

Use useRef when:

  • You need to access or control a DOM element (like .focus())
  • You want to store something between renders without re-rendering (like an interval ID or a previous value)

React Hooks Summary Table

React Hooks Guide: useEffect, useCallback, useRef, useMemo - Cloud Full-stack

Leave a Comment