← Back to all posts

Getting Started with React Hooks: A Game Changer

EhsanBy Ehsan
14 min read
ReactJavaScriptReact HooksuseStateuseEffectReact 16.8State ManagementWeb DevelopmentFunctional Components

2025 Update: Hooks are no longer the new kid on the block—they're the standard way to write React. React 18 and 19 brought powerful new Hooks like useTransition for non-blocking UI updates, useDeferredValue for performance optimization, and useId for generating unique IDs in server-side rendering. The fundamentals in this post still apply, but modern React development has evolved significantly. Class components are now rarely used, and Hooks are considered best practice.

Introduction

React 16.8 just came out, and it's bringing something really exciting: Hooks. If you've been writing React with classes, Hooks are about to change the way you think about components.

I've been playing with Hooks since the release, and I'm genuinely excited about this change. They solve real problems we've all faced with class components, and they make React code cleaner and easier to understand.

Whether you're new to React or you've been using it for a while, Hooks are worth learning. Let's dive in.

What Are Hooks?

Hooks let you use state and other React features in functional components—no classes needed.

Until now, if you wanted state in a component, you had to write a class. Functional components were "stateless" and could only receive props. Hooks change that completely.

// The old way - you need a class for state
class PlayCount extends React.Component {
  constructor(props) {
    super(props);
    this.state = { plays: 0 };
  }

  render() {
    return (
      <div>
        <p>Plays: {this.state.plays}</p>
        <button onClick={() => this.setState({ plays: this.state.plays + 1 })}>
          Play Song
        </button>
      </div>
    );
  }
}

// With Hooks - simple function component
function PlayCount() {
  const [plays, setPlays] = useState(0);

  return (
    <div>
      <p>Plays: {plays}</p>
      <button onClick={() => setPlays(plays + 1)}>
        Play Song
      </button>
    </div>
  );
}

Look at that difference. The Hook version is shorter, clearer, and you don't need to deal with this, constructors, or binding methods.

Why Hooks Matter

The React team didn't add Hooks just to make code shorter. They solve real problems that developers face every day.

Problem 1: Reusing Logic Is Hard

Ever tried to share stateful logic between components? You probably used render props or higher-order components (HOCs). They work, but they make your component tree messy and hard to follow.

Component Tree Without Hooks:
<WithUser>
  <WithTheme>
    <WithRouter>
      <YourActualComponent />
    </WithRouter>
  </WithTheme>
</WithUser>

Hooks let you extract and reuse logic without changing your component structure. You can create custom Hooks that share behavior across components—no wrapper components needed.

Problem 2: Class Components Get Messy

As components grow, lifecycle methods become a dumping ground for unrelated code. You might fetch data and set up event listeners in componentDidMount, then clean them up in different methods.

Related logic gets split across multiple methods. Unrelated logic ends up in the same method. It's hard to test, hard to maintain, and easy to introduce bugs.

Hooks let you organize code by what it does, not where it fits in the component lifecycle.

Problem 3: Classes Are Confusing

Let's be honest—classes in JavaScript are tricky, especially for beginners:

  • Understanding this and how it binds
  • Remembering to bind methods in the constructor
  • Knowing when to use arrow functions
  • Dealing with class inheritance

Hooks work with functions you already understand. No this, no binding, no confusion.

The Basic Hooks

Let's look at the two main Hooks you'll use most often.

useState - Adding State

useState adds state to functional components. It returns two things: the current state value and a function to update it.

Here's a music player example:

import { useState } from 'react';

function MusicPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [volume, setVolume] = useState(50);

  return (
    <div>
      <p>Status: {isPlaying ? 'Playing' : 'Paused'}</p>
      <p>Volume: {volume}%</p>

      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>

      <input
        type="range"
        min="0"
        max="100"
        value={volume}
        onChange={(e) => setVolume(Number(e.target.value))}
      />
    </div>
  );
}

You can use useState multiple times in the same component. Each call is independent—updating one doesn't affect the others.

How it works:

  • Call useState with the initial value
  • Get back an array with [currentValue, updateFunction]
  • Use array destructuring to name them whatever makes sense
  • Call the update function to change the state

Quick tip: You can use any initial value with useState—numbers, strings, booleans, objects, or arrays. Just remember that when updating objects or arrays, you need to create a new one (don't mutate the existing value).

useEffect - Handling Side Effects

useEffect replaces componentDidMount, componentDidUpdate, and componentWillUnmount. It handles side effects like data fetching, subscriptions, or manually changing the DOM.

Let's fetch a playlist:

import { useState, useEffect } from 'react';

function Playlist({ userId }) {
  const [songs, setSongs] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Fetch playlist when component mounts or userId changes
    setLoading(true);

    fetch(`/api/users/${userId}/playlist`)
      .then(response => response.json())
      .then(data => {
        setSongs(data.songs);
        setLoading(false);
      });
  }, [userId]); // Only re-run if userId changes

  if (loading) return <p>Loading playlist...</p>;

  return (
    <ul>
      {songs.map(song => (
        <li key={song.id}>
          {song.title} - {song.artist}
        </li>
      ))}
    </ul>
  );
}

The dependency array:

  • useEffect(fn, []) - Runs once after initial render (like componentDidMount)
  • useEffect(fn, [value]) - Runs when value changes
  • useEffect(fn) - Runs after every render (usually not what you want)

Cleaning Up Effects

Some effects need cleanup—like removing event listeners or canceling subscriptions. Return a cleanup function from your effect:

function NowPlaying({ songId }) {
  const [currentTime, setCurrentTime] = useState(0);

  useEffect(() => {
    // Subscribe to playback updates
    const interval = setInterval(() => {
      setCurrentTime(time => time + 1);
    }, 1000);

    // Cleanup function - runs before next effect and on unmount
    return () => {
      clearInterval(interval);
    };
  }, [songId]);

  return <p>Current time: {currentTime}s</p>;
}

React calls your cleanup function before running the effect again and when the component unmounts. This prevents memory leaks.

Comparing Class vs Hooks

Let's see a real example side by side. Here's a component that fetches song details and updates the document title:

Class Component Way

class SongDetails extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      song: null,
      loading: true,
      error: null
    };
  }

  componentDidMount() {
    this.fetchSong();
  }

  componentDidUpdate(prevProps) {
    if (prevProps.songId !== this.props.songId) {
      this.fetchSong();
    }
  }

  componentWillUnmount() {
    // Cancel any pending requests
    this.abortController.abort();
  }

  fetchSong() {
    this.setState({ loading: true, error: null });
    this.abortController = new AbortController();

    fetch(`/api/songs/${this.props.songId}`, {
      signal: this.abortController.signal
    })
      .then(res => res.json())
      .then(song => {
        this.setState({ song, loading: false });
        document.title = `${song.title} - ${song.artist}`;
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          this.setState({ error, loading: false });
        }
      });
  }

  render() {
    const { song, loading, error } = this.state;

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error.message}</p>;
    if (!song) return null;

    return (
      <div>
        <h2>{song.title}</h2>
        <p>Artist: {song.artist}</p>
        <p>Album: {song.album}</p>
        <p>Duration: {song.duration}s</p>
      </div>
    );
  }
}

Hooks Way

function SongDetails({ songId }) {
  const [song, setSong] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    const abortController = new AbortController();

    fetch(`/api/songs/${songId}`, {
      signal: abortController.signal
    })
      .then(res => res.json())
      .then(song => {
        setSong(song);
        setLoading(false);
        document.title = `${song.title} - ${song.artist}`;
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          setError(error);
          setLoading(false);
        }
      });

    // Cleanup function
    return () => abortController.abort();
  }, [songId]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!song) return null;

  return (
    <div>
      <h2>{song.title}</h2>
      <p>Artist: {song.artist}</p>
      <p>Album: {song.album}</p>
      <p>Duration: {song.duration}s</p>
    </div>
  );
}

The Hooks version is shorter, but more importantly, it's easier to follow. All the related logic—fetching data, updating title, cleanup—lives together in one useEffect.

More Advanced Hooks

Once you're comfortable with useState and useEffect, there are other built-in Hooks worth exploring.

useContext - Sharing Data

useContext lets you subscribe to React context without nesting:

import { useContext } from 'react';
import { ThemeContext } from './ThemeContext';

function MusicPlayer() {
  const theme = useContext(ThemeContext);

  return (
    <div style={{ background: theme.background, color: theme.text }}>
      <p>Now Playing...</p>
    </div>
  );
}

No more <ThemeContext.Consumer> wrapper needed. Just call useContext and you get the value.

useRef - Persisting Values

useRef gives you a mutable value that persists across renders. It's perfect for storing things like audio elements:

function AudioPlayer({ src }) {
  const audioRef = useRef(null);
  const [isPlaying, setIsPlaying] = useState(false);

  const togglePlay = () => {
    if (isPlaying) {
      audioRef.current.pause();
    } else {
      audioRef.current.play();
    }
    setIsPlaying(!isPlaying);
  };

  return (
    <div>
      <audio ref={audioRef} src={src} />
      <button onClick={togglePlay}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
    </div>
  );
}

The ref persists between renders but doesn't trigger re-renders when it changes—unlike state.

Custom Hooks: Reusing Logic

Here's where Hooks get really powerful. You can create your own Hooks to share logic between components.

If you're dealing with server data fetching in production apps, you might want to check out React Query (TanStack Query) which provides a robust solution for caching, refetching, and managing server state. For client state management, Jotai's atomic state approach works beautifully with Hooks.

Let's extract the song fetching logic into a custom Hook:

function useSong(songId) {
  const [song, setSong] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    const abortController = new AbortController();

    fetch(`/api/songs/${songId}`, {
      signal: abortController.signal
    })
      .then(res => res.json())
      .then(song => {
        setSong(song);
        setLoading(false);
      })
      .catch(error => {
        if (error.name !== 'AbortError') {
          setError(error);
          setLoading(false);
        }
      });

    return () => abortController.abort();
  }, [songId]);

  return { song, loading, error };
}

Now any component can use this Hook:

function SongDetails({ songId }) {
  const { song, loading, error } = useSong(songId);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  if (!song) return null;

  return (
    <div>
      <h2>{song.title}</h2>
      <p>Artist: {song.artist}</p>
    </div>
  );
}

function SongCard({ songId }) {
  const { song, loading } = useSong(songId);

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

  return <div className="card">{song.title}</div>;
}

Both components use the same logic, but they render different UI. No HOCs, no render props, no wrapper components. Just call the Hook.

Custom Hook rules:

  • Name must start with "use" (like useSong, usePlaylist)
  • Can call other Hooks inside
  • Each component gets its own isolated state
  • Can return anything—values, functions, objects

Here's another example—a Hook for window size:

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

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

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// Use it anywhere
function ResponsiveMusicPlayer() {
  const { width } = useWindowSize();
  const isMobile = width < 768;

  return (
    <div className={isMobile ? 'mobile-player' : 'desktop-player'}>
      {/* Player UI */}
    </div>
  );
}

Rules of Hooks

Hooks have two rules you must follow. They might seem arbitrary, but they're important for Hooks to work correctly.

Rule 1: Only Call Hooks at the Top Level

Don't call Hooks inside loops, conditions, or nested functions. Always use Hooks at the top of your function component.

// ❌ Wrong - conditional Hook
function BadExample({ isPremium }) {
  if (isPremium) {
    const [volume, setVolume] = useState(100); // Don't do this!
  }
}

// ✅ Correct - Hook at top level
function GoodExample({ isPremium }) {
  const [volume, setVolume] = useState(100);

  if (isPremium) {
    // Use the state here instead
    setVolume(100);
  }
}

React relies on the order Hooks are called to keep track of state. If you call Hooks conditionally, the order can change between renders, breaking things.

Rule 2: Only Call Hooks from React Functions

Only call Hooks from:

  • React function components
  • Custom Hooks

Don't call Hooks from regular JavaScript functions.

// ❌ Wrong - Hook in regular function
function calculatePlaytime() {
  const [playtime, setPlaytime] = useState(0); // Don't do this!
  return playtime;
}

// ✅ Correct - Hook in custom Hook
function usePlaytime() {
  const [playtime, setPlaytime] = useState(0);
  return { playtime, setPlaytime };
}

ESLint Plugin

Install the official ESLint plugin to catch these mistakes automatically:

npm install eslint-plugin-react-hooks --save-dev

It'll warn you if you break the rules. Really helpful when learning Hooks.

Migration Strategy

You don't need to rewrite your entire app to use Hooks. The React team designed them to work alongside classes.

Gradual approach:

  1. Keep existing class components—they still work fine
  2. Write new components with Hooks
  3. Refactor classes to Hooks as you touch that code
  4. No rush—Hooks and classes can coexist
Your App Structure:
├── UserProfile (class component - keep it for now)
├── Sidebar (class component - keep it for now)
├── MusicPlayer (new component - use Hooks!)
└── Playlist (refactored from class - now uses Hooks!)

Start with small components. Get comfortable with useState and useEffect first. Then try custom Hooks. You'll develop intuition for when Hooks make sense.

Common Patterns

Here are some practical patterns I've found useful.

Fetching Data

For production apps, consider using React Query for data fetching instead of building this from scratch—it handles caching, background refetching, and error states automatically.

function useAPI(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
}

Form Handling

function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);

  const handleChange = (e) => {
    setValues({
      ...values,
      [e.target.name]: e.target.value
    });
  };

  const reset = () => setValues(initialValues);

  return { values, handleChange, reset };
}

// Usage
function CreatePlaylist() {
  const { values, handleChange, reset } = useForm({
    name: '',
    description: ''
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    // Submit values
    reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={values.name} onChange={handleChange} />
      <input name="description" value={values.description} onChange={handleChange} />
      <button type="submit">Create</button>
    </form>
  );
}

Local Storage Sync

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage - persist volume setting
function VolumeControl() {
  const [volume, setVolume] = useLocalStorage('volume', 50);

  return (
    <input
      type="range"
      value={volume}
      onChange={(e) => setVolume(Number(e.target.value))}
    />
  );
}

What's Next?

Hooks are here to stay. The React team is betting on them as the future of React development, and after using them, I understand why.

They make code more readable, logic more reusable, and React more approachable. You don't need to understand class components, lifecycle methods, or this binding to write great React code.

Once you're comfortable with Hooks, explore how they integrate with modern state management solutions like Jotai for React Native apps or TanStack Query for server state. These libraries are built on top of Hooks and make complex state management feel natural.

Start experimenting with Hooks in your next component. Try useState for simple state. Use useEffect for side effects. Create a custom Hook when you find yourself repeating logic.

The React community is already building amazing custom Hooks you can learn from. Check out GitHub—there are Hooks for everything from animations to API calls to form validation.

This is an exciting time for React. Hooks open up new possibilities for how we write components and share logic. Dive in and see what you can build!

Frequently Asked Questions

When should I use useEffect?

Use useEffect for side effects like data fetching, subscriptions, or DOM manipulation. It runs after render and can clean up when the component unmounts or dependencies change.

Can I use Hooks with class components?

No, Hooks only work in functional components. However, you can gradually adopt Hooks by writing new components as functions while keeping existing class components unchanged.

What are custom Hooks?

Custom Hooks are functions that start with "use" and can call other Hooks. They let you extract and reuse stateful logic across components without changing component structure.

Why can't I call Hooks conditionally?

React relies on the order Hooks are called to track state between renders. Calling Hooks conditionally would change this order and break state tracking. Always call Hooks at the top level. Install the ESLint plugin for Hooks to catch these mistakes automatically.

Do I need to rewrite my entire app to use Hooks?

No. Hooks work alongside class components. Write new components with Hooks and gradually refactor old ones as needed. There's no rush—both approaches coexist perfectly.

Resources