index

react-hooks

· 5min

this guide is a deep dive into the six most essential hooks, We’ll explore useState, useEffect, useContext, useReducer, useMemo, and useCallback with practical examples that reveal how they work under the hood.


useState

it is the first hook everyone learns. It gives us a state variable and a function to update it but its simplicity hides two crucial concepts: functional updates and immutability.

Functional Updates

Imagine you want a button to increment a counter by three.

will you write like this using useState?

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

const handleTripleClick = () => {
  setCount(count + 1); 
  setCount(count + 1); 
  setCount(count + 1); 
};

it is wrong! as this code only increments the count to 1. Why? Because count has the same value (0) within a single render. We’re requesting the react scheduler three time which ultimately batches the similar requests together and renders the last one.

The solution is a functional update, which gives us the guaranteed latest state.

const handleTripleClick = () => {
  setCount(prevCount => prevCount + 1); // Takes 0, returns 1
  setCount(prevCount => prevCount + 1); // Takes 1, returns 2
  setCount(prevCount => prevCount + 1); // Takes 2, returns 3
};

when your new state depends on the previous state, always use the functional update form to avoid bugs from stale state.

Immutability

What happens when your state is an object? would you handle it like this?

const [user, setUser] = useState({ name: 'Alex', age: 25 });

const handleAgeUpdate = () => {
  user.age = 26;
  setUser(user); 
};

This will not re-render the component. React decides whether to re-render by checking if the state object is the exact same one in memory (Object.is). By modifying the original user object, you’re handing React the same object back. It sees no change and does nothing.

The solution is to create a new object.

const handleAgeUpdate = () => {
  const newUser = { ...user, age: 26 };
  setUser(newUser); 
};

never mutate state directly. For objects and arrays, always create a new one with your changes (using the spread ... operator) to trigger a re-render.


useEffect

it is for interacting with the world outside of your component, like fetching data or setting up subscriptions. Its behavior is controlled entirely by its dependency array.

The Three Rules of the Dependency Array

  1. [userId]: The effect runs on the first render and re-runs only if the dependency changes. This is perfect for fetching data when a prop like userId changes.
  2. [empty] : The effect runs only once when the component first mounts. This is ideal for initial data fetches.
  3. No Array: The effect runs after every single render. This is dangerous and can easily cause infinite loops if your effect updates the state!

Cleanup Function

If your effect sets up a timer, you must clean it up after it unmounts. useEffect lets you return a function to handle this.

Consider a component that subscribes to a chat room.

useEffect(() => {
  // Effect: Connects to the room
  chatApi.subscribe(roomId, handleMessage);
  
  // Cleanup: Runs before the component unmounts OR before the effect runs again
  return () => {
    chatApi.unsubscribe(roomId);
  };
}, [roomId]); // Re-runs if roomId changes

If roomId changes, React first runs the cleanup function to unsubscribe from the old room and then runs the effect to subscribe to the new one. This prevents memory leaks and ensures your component is always synced with the current props.

master the dependency array to control your effects, and always return a cleanup function for clean code.


useContext

it solves “prop drilling”—the painful process of passing props through many layers of components.

Let’s imagine a theme (dark) that many components need.

  1. Create the Context: export const ThemeContext = createContext('dark');
  2. Provide the Context: In your App.js, wrap your component tree.
     <ThemeContext.Provider value={theme}>
         <Page />
     </ThemeContext.Provider>
  3. Consume the Context: Any child component can now access the theme directly.
    function Button() {
      const theme = useContext(ThemeContext); 
      return <button className={theme}>I am a {theme} button</button>;
    }

it acts like a public announcement system, allowing distant components to share data without passing props.


useReducer

When you find yourself juggling multiple useState hooks for related data, it’s time for useReducer. It centralizes your state update logic.

Instead of calling multiple setState functions, you dispatch an action. A reducer function takes that action and calculates the new state.

here’s a data-fetching reducer:

function dataReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { isLoading: true, data: null, error: null };
    case 'FETCH_SUCCESS':
      return { isLoading: false, data: action.payload, error: null };
    case 'FETCH_FAILURE':
      return { isLoading: false, data: null, error: action.payload };
    default:
      throw new Error();
  }
}

Your component becomes simple: it just dispatches actions describing what happened, and the reducer handles the logic.

it makes complex state transitions more predictable and keeps your code clean by separating logic which is what makes react, react.


useMemo, useCallback

why am i putting these together in the blog? as once you understand useMemo you will eventually get hold of useCallback.

useMemo

If a component re-renders, it re-runs all calculations inside it, what if the calculation is resource intensive it will make your app laggy.

const largestPrime = findLargestPrime(numbers);

useMemo caches the result of a calculation, re-running it only if its dependencies change(it also has a dependency array like of useEffect)

const largestPrime = useMemo(() => {
  return findLargestPrime(numbers);
}, [numbers]);

useCallback

functions defined inside components are recreated on every render. While that usually works fine but it can lead to unnecessary child re-renders especially when you pass functions as props to components wrapped in React.memo.

useCallback returns a memoized version of a function that stays identical across renders unless its dependencies change.

const memoizedHandler = useCallback(() => {
  // Some stable logic
}, [someDependency]);

here, the function memoizedHandler won’t be recreated unless dependency changes.

check for what happens in the codeblock, try it yourself by copying the code

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

  const increment = () => setCount(c => c + 1);

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <Child increment={increment} count={count} />
    </>
  );
}

const Child = React.memo(({ increment, count }) => {
  console.log("Child rendered");
  return <button onClick={increment}>Count: {count}</button>;
});

Here, typing in the input re-renders the Child as well, because the increment function is recreated on every render.

useCallback fixes this performance issue

const increment = useCallback(
  () => setCount(c => c + 1),
  []
);

the increment function is improved now. The Child component will only re-render when count changes, not when text changes.

useMemo memoizes a value. useCallback memoizes a function. Use them to prevent expensive re-calculations and unnecessary re-renders in child components.


Conclusion

The key to mastering React hooks is understanding when and why to use each one.

Start with the fundamentals hooks first (useState and useEffect) use them to write clean components

as you gradually need performance and optimization make use of these hooks


never stop building