Frontend Development

React Hooks Explained: useState and useEffect

Mayur Dabhi
Mayur Dabhi
February 17, 2026
15 min read

React Hooks revolutionized how we write React components when they were introduced in React 16.8. They allow you to use state and other React features without writing class components. In this comprehensive guide, we'll dive deep into the two most fundamental hooks: useState and useEffect. By the end, you'll understand not just how to use them, but why they work the way they do.

Why Hooks Matter

Before Hooks, sharing stateful logic between components required complex patterns like render props or higher-order components. Hooks let you extract and reuse stateful logic without changing your component hierarchy. This makes your code simpler, more readable, and easier to test.

The Evolution: From Classes to Hooks

Before we dive into hooks, let's understand why they exist. Class components in React worked well but had several pain points:

Hooks solve all these problems by letting you use state and lifecycle features in functional components. Let's see the difference:

Counter.jsx (Class)
import React, { Component } from 'react';

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.increment = this.increment.bind(this);
  }

  componentDidMount() {
    document.title = `Count: ${this.state.count}`;
  }

  componentDidUpdate() {
    document.title = `Count: ${this.state.count}`;
  }

  increment() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}
Counter.jsx (Hooks)
import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

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

The hooks version is significantly shorter, easier to read, and avoids the this binding issues. Now let's explore each hook in detail.

Understanding useState

The useState hook is your gateway to adding state to functional components. It returns a pair: the current state value and a function to update it.

useState(0) Initial Value returns [count, setCount] count Current State setCount Updater Function

useState returns an array with the current state and an updater function

Basic Syntax

useState Syntax
const [state, setState] = useState(initialValue);

// Examples with different types
const [count, setCount] = useState(0);           // Number
const [name, setName] = useState('');            // String
const [isOpen, setIsOpen] = useState(false);     // Boolean
const [items, setItems] = useState([]);          // Array
const [user, setUser] = useState(null);          // Object or null
const [form, setForm] = useState({               // Object
  username: '',
  email: '',
  password: ''
});
Important: Array Destructuring

The [state, setState] syntax is array destructuring. You can name these variables anything you want, but the convention is value and setValue. The order matters: state first, setter second.

Updating State

There are two ways to update state with the setter function:

State Updates
// 1. Direct value update
setCount(5);
setName('John');
setIsOpen(true);

// 2. Functional update (recommended when new state depends on previous)
setCount(prevCount => prevCount + 1);
setItems(prevItems => [...prevItems, newItem]);
setUser(prevUser => ({ ...prevUser, name: 'Jane' }));
Pro Tip: Use Functional Updates

When your new state depends on the previous state, always use the functional form: setState(prev => newValue). This ensures you're working with the most current state, especially important when multiple updates happen rapidly.

Working with Objects and Arrays

State updates in React must be immutable. Never mutate state directly—always create new references:

Immutable Updates
// ❌ WRONG - Mutating state directly
user.name = 'Jane';
setUser(user);

// ✅ CORRECT - Creating new object
setUser({ ...user, name: 'Jane' });
// Or with functional update:
setUser(prev => ({ ...prev, name: 'Jane' }));

// ❌ WRONG - Mutating array
items.push(newItem);
setItems(items);

// ✅ CORRECT - Creating new array
setItems([...items, newItem]);
// Or with functional update:
setItems(prev => [...prev, newItem]);

// Removing from array
setItems(prev => prev.filter(item => item.id !== idToRemove));

// Updating item in array
setItems(prev => prev.map(item => 
  item.id === idToUpdate ? { ...item, completed: true } : item
));

Lazy Initialization

If your initial state requires expensive computation, pass a function to useState instead of a value:

Lazy Initialization
// ❌ This runs on every render
const [data, setData] = useState(expensiveComputation());

// ✅ This only runs once on initial render
const [data, setData] = useState(() => expensiveComputation());

// Practical example: reading from localStorage
const [theme, setTheme] = useState(() => {
  const saved = localStorage.getItem('theme');
  return saved ? JSON.parse(saved) : 'dark';
});

Understanding useEffect

useEffect is the hook for handling side effects in functional components. Side effects are operations that reach outside the component: data fetching, subscriptions, manually changing the DOM, logging, etc.

Component Renders JSX → DOM Effect Runs After paint Dependency Array No array Runs every render [] Runs once on mount [dep1, dep2] Runs when deps change Cleanup Function Runs before next effect

useEffect lifecycle: render → effect → cleanup (if returned)

Basic Syntax

useEffect Syntax
useEffect(() => {
  // Effect logic here (runs after render)
  
  return () => {
    // Cleanup logic here (optional)
    // Runs before component unmounts
    // Also runs before effect re-runs
  };
}, [dependencies]); // Dependency array (optional)

The Three Dependency Array Patterns

The dependency array controls when your effect runs. Understanding this is crucial:

Pattern Syntax When It Runs Use Case
No Array useEffect(() => {}) Every render Rarely needed; usually a mistake
Empty Array useEffect(() => {}, []) Only on mount Initial data fetch, setup subscriptions
With Deps useEffect(() => {}, [a, b]) When deps change React to state/prop changes

Practical Examples

Example 1: Data Fetching

DataFetching.jsx
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // Reset state when userId changes
    setLoading(true);
    setError(null);

    // Create abort controller for cleanup
    const controller = new AbortController();

    async function fetchUser() {
      try {
        const response = await fetch(
          `https://api.example.com/users/${userId}`,
          { signal: controller.signal }
        );
        
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        
        const data = await response.json();
        setUser(data);
      } catch (err) {
        // Ignore abort errors
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUser();

    // Cleanup: cancel pending request
    return () => controller.abort();
  }, [userId]); // Re-fetch when userId changes

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

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

Example 2: Setting Up Intervals

Timer.jsx
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // Only set up interval if timer is running
    if (!isRunning) return;

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

    // Cleanup: clear interval when component unmounts
    // or when isRunning changes to false
    return () => clearInterval(intervalId);
  }, [isRunning]); // Re-run when isRunning changes

  return (
    <div>
      <h2>{seconds} seconds</h2>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => setSeconds(0)}>Reset</button>
    </div>
  );
}

Example 3: Event Listeners

WindowSize.jsx
import { useState, useEffect } from 'react';

function WindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    // Add event listener
    window.addEventListener('resize', handleResize);

    // Cleanup: remove event listener
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Empty array = only on mount/unmount

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

Example 4: Local Storage Sync

useLocalStorage.jsx
import { useState, useEffect } from 'react';

// Custom hook for localStorage sync
function useLocalStorage(key, initialValue) {
  // Initialize state from localStorage or use default
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });

  // Sync to localStorage whenever value changes
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'dark');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);

  return (
    <div>
      <select value={theme} onChange={e => setTheme(e.target.value)}>
        <option value="dark">Dark</option>
        <option value="light">Light</option>
      </select>
      <input
        type="range"
        min="12"
        max="24"
        value={fontSize}
        onChange={e => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

Common Mistakes and How to Avoid Them

Mistake 1: Missing Dependencies

Missing Dependencies
// ❌ WRONG - count is used but not in dependencies
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Always logs initial value (0)!
    }, 1000);
    return () => clearInterval(id);
  }, []); // Missing 'count' dependency
}

// ✅ CORRECT - Use functional update to avoid dependency
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1); // Uses latest count via callback
    }, 1000);
    return () => clearInterval(id);
  }, []); // No dependency needed!
}

Mistake 2: Object/Array Dependencies

Object Dependencies
// ❌ WRONG - Creates new object every render, infinite loop!
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  const options = { query, limit: 10 }; // New object each render!

  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // Always triggers because options !== options
}

// ✅ CORRECT - Stable dependencies
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const limit = 10;

  useEffect(() => {
    fetchResults({ query, limit }).then(setResults);
  }, [query, limit]); // Primitive values are stable
}

// ✅ ALSO CORRECT - useMemo for complex objects
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  const options = useMemo(() => ({
    query,
    limit: 10
  }), [query]);

  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // Now stable between renders
}

Mistake 3: Forgetting Cleanup

Memory Leaks
// ❌ WRONG - Memory leak! Event listener never removed
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
}, []);

// ✅ CORRECT - Always clean up subscriptions/listeners
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

// ❌ WRONG - State update on unmounted component
useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data)); // May error if unmounted
}, []);

// ✅ CORRECT - Cancel or ignore on unmount
useEffect(() => {
  let isMounted = true;
  
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (isMounted) setData(data);
    });

  return () => { isMounted = false; };
}, []);
The ESLint Plugin

Install eslint-plugin-react-hooks and enable the exhaustive-deps rule. It will warn you about missing dependencies and help prevent subtle bugs. Trust the linter—it's usually right!

Multiple useState and useEffect

You can (and should!) use multiple hooks in a single component. Keep related state together, separate unrelated state:

Multiple Hooks
function Dashboard() {
  // Separate concerns into different state variables
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [notifications, setNotifications] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  // Effect for user data
  useEffect(() => {
    fetchUser().then(setUser);
  }, []);

  // Effect for posts (depends on user)
  useEffect(() => {
    if (!user) return;
    fetchPosts(user.id).then(setPosts);
  }, [user]);

  // Effect for notifications (polling)
  useEffect(() => {
    if (!user) return;
    
    const fetchNotifs = () => {
      fetchNotifications(user.id).then(setNotifications);
    };
    
    fetchNotifs(); // Initial fetch
    const id = setInterval(fetchNotifs, 30000); // Poll every 30s
    
    return () => clearInterval(id);
  }, [user]);

  // Effect to update loading state
  useEffect(() => {
    if (user && posts.length > 0) {
      setIsLoading(false);
    }
  }, [user, posts]);

  // ... render logic
}

Rules of Hooks

React enforces two important rules for hooks. Breaking them causes bugs:

Rule 1: Top Level Only

Don't call hooks inside loops, conditions, or nested functions

Rule 2: React Functions Only

Only call hooks from React function components or custom hooks

Why It Matters

React relies on call order to track hooks between renders

Rules of Hooks
// ❌ WRONG - Hook inside condition
function Form({ isEditing }) {
  if (isEditing) {
    const [value, setValue] = useState(''); // Never do this!
  }
}

// ❌ WRONG - Hook inside loop
function List({ items }) {
  items.forEach(item => {
    useEffect(() => { /* ... */ }); // Never do this!
  });
}

// ✅ CORRECT - Hooks at top level, conditions inside
function Form({ isEditing }) {
  const [value, setValue] = useState('');
  
  useEffect(() => {
    if (isEditing) { // Condition inside hook is fine
      // ... effect logic
    }
  }, [isEditing]);
}

// ✅ CORRECT - For lists, put hook in child component
function List({ items }) {
  return items.map(item => (
    <ListItem key={item.id} item={item} />
  ));
}

function ListItem({ item }) {
  useEffect(() => { /* ... */ }, [item]); // Each instance has its own hook
  return <div>{item.name}</div>;
}

When to Create Custom Hooks

Custom hooks let you extract component logic into reusable functions. Create a custom hook when:

Custom Hook Example
// Custom hook for API calls
function useApi(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    
    setLoading(true);
    setError(null);
    
    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error('Request failed');
        return res.json();
      })
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage - so clean!
function UserProfile({ userId }) {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <Profile user={user} />;
}

function PostList() {
  const { data: posts, loading } = useApi('/api/posts');
  // ...
}

Summary and Best Practices

Key Takeaways

  • useState: For managing local component state. Use functional updates when new state depends on previous state.
  • useEffect: For side effects after render. Always specify dependencies and clean up subscriptions.
  • Dependencies: Include all values from component scope that change over time and are used in the effect.
  • Cleanup: Return a cleanup function for subscriptions, timers, and event listeners.
  • Custom Hooks: Extract reusable stateful logic into custom hooks prefixed with use.
Next Steps

Now that you've mastered useState and useEffect, explore these other hooks:

  • useContext - Access context without nesting
  • useReducer - Complex state logic
  • useMemo and useCallback - Performance optimization
  • useRef - Mutable values and DOM references

React Hooks have fundamentally changed how we write React applications. They make our code more readable, testable, and reusable. Start with useState and useEffect, practice the patterns we've covered, and you'll be writing elegant React code in no time!

React Hooks useState useEffect JavaScript Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications. Passionate about React, Node.js, and modern web technologies.