A Deep Dive into Basic React Hooks: useState, useEffect, and useContext | Chandrashekhar Kachawa | Tech Blog

A Deep Dive into Basic React Hooks: useState, useEffect, and useContext

react

Hooks revolutionized how we write React components. They allow us, for the first time, to use state, side effects, and other React features in functional components. While the list of Hooks has grown, a solid understanding of the three foundational hooks—useState, useEffect, and useContext—is the key to becoming a proficient React developer.

This guide provides a deep dive into each one, covering not just how to use them, but also why they exist and the technical concepts that make them work.

1. useState: Giving Components a Memory

Why use it? Functional components are, by design, just functions. When React re-renders a component, it re-runs the entire function. By default, they are stateless and have no memory of what happened in previous renders. useState solves this fundamental problem by providing a “memory cell” for your component.

The Technical Idea When you call useState, you are asking React to create and manage a piece of state for your component instance. React holds this value between renders. The hook returns an array with two elements: the current state value and a special function to update that value. This update function doesn’t change the state immediately. Instead, it schedules a re-render of the component with the new state value.

How to Use It

You call useState with an initial value. It returns the current value and a function to update it, which we typically destructure from an array.

import { useState } from 'react';

function Counter() {
  // The initial state is 0
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      {/* The setter function schedules a re-render with the new value */}
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

Working with Objects and Arrays State updates replace the old state. If your state is an object, you must manually merge the old data.

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

// To update only the age, you must spread the old state
const updateAge = () => {
  setUser(previousUser => ({
    ...previousUser, // Keep the old properties
    age: 31,          // Overwrite the one you want to change
  }));
};

Using the functional update form (setUser(previousUser => ...)), as shown above, is a best practice. It ensures you are working with the most recent state, preventing bugs related to stale state in asynchronous operations.


2. useEffect: Synchronizing with the Outside World

Why use it? Your components often need to interact with systems outside of the React ecosystem. This could be fetching data from an API, setting up a timer with setInterval, or manually manipulating a DOM element. These operations are called side effects. useEffect provides a dedicated place to run them, ensuring they don’t block rendering and are managed correctly within the component lifecycle.

The Technical Idea useEffect schedules a function (your “effect”) to run after React has committed all changes to the DOM. This prevents your side effect logic from slowing down the user interface. You can control when your effect re-runs by providing a dependency array. React will compare the values in this array between renders, and if any value has changed, it will re-run the effect.

How to Use It

The behavior of useEffect is controlled by its second argument, the dependency array.

1. Run After Every Render If you provide no dependency array, the effect runs after every single render.

useEffect(() => {
  // This runs on initial render AND every time the component updates
  console.log('Component re-rendered');
});

2. Run Only Once An empty dependency array ([]) means the effect has no dependencies on props or state, so it will only run once after the initial render. This is perfect for initial data fetching.

useEffect(() => {
  // This runs only once, after the component first mounts
  fetch('https://api.example.com/data')
    .then(res => res.json())
    .then(setData);
}, []); // Empty array means "run once"

3. Run When Dependencies Change If you pass values to the dependency array, the effect will re-run whenever those values change.

useEffect(() => {
  // This effect re-runs whenever `userId` changes
  fetch(`https://api.example.com/users/${userId}`)
    .then(res => res.json())
    .then(setUser);
}, [userId]); // Dependency array

The Cleanup Function What if your effect sets up a subscription or a timer? You need to clean it up when the component unmounts to prevent memory leaks. You can do this by returning a function from your effect. React will execute this cleanup function before the component is removed from the DOM, or before the effect re-runs.

useEffect(() => {
  const timerId = setInterval(() => {
    console.log('Tick');
  }, 1000);

  // Return a cleanup function
  return () => {
    console.log('Cleaning up the timer!');
    clearInterval(timerId);
  };
}, []);

3. useContext: Escaping “Prop Drilling”

Why use it? Sometimes, you have state that many components need to access, but these components are nested deep within the component tree. Passing this state down as props through every single layer is tedious and makes refactoring a nightmare. This problem is called prop drilling. useContext solves this by allowing you to broadcast state to all components below a certain point in the tree without passing props.

The Technical Idea useContext works with the React Context API. You first create a Context object. Then, you use a special component called a Provider high up in your component tree to hold and provide a value. Any descendant component, no matter how deep, can then use the useContext hook to “tune in” and read that value. When the value in the Provider changes, all components that consume that context will automatically re-render.

How to Use It

It’s a three-step process:

Step 1: Create the Context Create a context object outside of your components. The argument to createContext is the default value.

// theme-context.js
import { createContext } from 'react';

export const ThemeContext = createContext('light'); // Default value is 'light'

Step 2: Provide the Context Wrap a parent component with the Context.Provider and pass it the value you want to share.

// App.js
import { useState } from 'react';
import { ThemeContext } from './theme-context';
import Toolbar from './Toolbar';

function App() {
  const [theme, setTheme] = useState('dark');

  return (
    <ThemeContext.Provider value={theme}>
      {/* All components inside Toolbar can now access the theme */}
      <Toolbar />
    </ThemeContext.Provider>
  );
}

Step 3: Consume the Context In any child component, use the useContext hook to read the value.

// ThemedButton.js (deep inside Toolbar)
import { useContext } from 'react';
import { ThemeContext } from './theme-context';

function ThemedButton() {
  const theme = useContext(ThemeContext); // Reads the value from the nearest Provider

  const style = {
    background: theme === 'dark' ? '#333' : '#FFF',
    color: theme === 'dark' ? '#FFF' : '#333',
  };

  return <button style={style}>A {theme} button</button>;
}

By mastering these three hooks, you build a solid foundation for creating powerful, efficient, and scalable applications with React.


Conclusion

useState, useEffect, and useContext are the cornerstones of modern React development. They provide a simple yet powerful API to manage component state, handle side effects, and share data across your application. While React offers more specialized hooks for advanced scenarios, a deep understanding of these three is non-negotiable for any developer looking to write clean, maintainable, and efficient React code.

By moving beyond just how to use them and understanding why they exist, you unlock the ability to think more like a React developer and build more robust applications.

Latest Posts

Enjoyed this article? Follow me on X for more content and updates!

Follow @Ctrixdev