Complete React Hooks Mastery - From Basics to Advanced

intermediate | 60 min read | 2024.12.15

What You’ll Learn in This Tutorial

✓ useState - State management basics
✓ useEffect - Side effect handling
✓ useContext - Global state sharing
✓ useReducer - Complex state management
✓ useMemo/useCallback - Performance optimization
✓ Custom Hooks - Logic reuse

Prerequisites

  • Basic JavaScript knowledge
  • Basic understanding of React (components, props)
  • Node.js installed

Project Setup

# Create project
npx create-react-app hooks-tutorial
cd hooks-tutorial

# Start development server
npm start

Step 1: useState - State Management Basics

Basic Usage

// src/components/Counter.jsx
import { useState } from 'react';

export default function Counter() {
  // [state value, update function] = useState(initial value)
  const [count, setCount] = useState(0);

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

Object State Management

// src/components/UserForm.jsx
import { useState } from 'react';

export default function UserForm() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    age: ''
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    // Use spread operator to preserve existing values while updating
    setUser(prev => ({
      ...prev,
      [name]: value
    }));
  };

  return (
    <form>
      <input
        name="name"
        value={user.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <input
        name="email"
        value={user.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <input
        name="age"
        type="number"
        value={user.age}
        onChange={handleChange}
        placeholder="Age"
      />
      <pre>{JSON.stringify(user, null, 2)}</pre>
    </form>
  );
}

Array State Management

// src/components/TodoList.jsx
import { useState } from 'react';

export default function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    if (!input.trim()) return;
    setTodos([...todos, { id: Date.now(), text: input, done: false }]);
    setInput('');
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
              onClick={() => toggleTodo(todo.id)}
            >
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Step 2: useEffect - Side Effect Handling

Basic useEffect

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

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

  useEffect(() => {
    let interval = null;

    if (isRunning) {
      interval = setInterval(() => {
        setSeconds(prev => prev + 1);
      }, 1000);
    }

    // Cleanup function
    return () => {
      if (interval) clearInterval(interval);
    };
  }, [isRunning]); // Runs when isRunning changes

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

Data Fetching

// src/components/UserList.jsx
import { useState, useEffect } from 'react';

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const response = await fetch(
          'https://jsonplaceholder.typicode.com/users'
        );
        if (!response.ok) throw new Error('Failed to fetch data');
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []); // Empty dependency array = runs only on mount

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

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} ({user.email})</li>
      ))}
    </ul>
  );
}

Syncing with Local Storage

// src/components/PersistentCounter.jsx
import { useState, useEffect } from 'react';

export default function PersistentCounter() {
  const [count, setCount] = useState(() => {
    // Lazy initialization
    const saved = localStorage.getItem('count');
    return saved ? JSON.parse(saved) : 0;
  });

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

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

Step 3: useContext - Global State Sharing

Creating and Using Context

// src/context/ThemeContext.jsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}
// src/components/ThemedApp.jsx
import { useTheme } from '../context/ThemeContext';

export default function ThemedApp() {
  const { theme, toggleTheme } = useTheme();

  const styles = {
    container: {
      backgroundColor: theme === 'light' ? '#fff' : '#333',
      color: theme === 'light' ? '#333' : '#fff',
      padding: '20px',
      minHeight: '100vh'
    }
  };

  return (
    <div style={styles.container}>
      <h1>Current theme: {theme}</h1>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
}
// src/App.jsx
import { ThemeProvider } from './context/ThemeContext';
import ThemedApp from './components/ThemedApp';

export default function App() {
  return (
    <ThemeProvider>
      <ThemedApp />
    </ThemeProvider>
  );
}

Step 4: useReducer - Complex State Management

Implementing Reducer Pattern

// src/components/ShoppingCart.jsx
import { useReducer } from 'react';

const initialState = {
  items: [],
  total: 0
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find(
        item => item.id === action.payload.id
      );

      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          ),
          total: state.total + action.payload.price
        };
      }

      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }],
        total: state.total + action.payload.price
      };
    }

    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload);
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload),
        total: state.total - (item.price * item.quantity)
      };
    }

    case 'CLEAR_CART':
      return initialState;

    default:
      return state;
  }
}

const products = [
  { id: 1, name: 'Apple', price: 100 },
  { id: 2, name: 'Banana', price: 80 },
  { id: 3, name: 'Orange', price: 120 }
];

export default function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <div>
      <h2>Products</h2>
      {products.map(product => (
        <div key={product.id}>
          {product.name} - ${product.price}
          <button onClick={() => dispatch({
            type: 'ADD_ITEM',
            payload: product
          })}>
            Add to Cart
          </button>
        </div>
      ))}

      <h2>Cart</h2>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name} x {item.quantity} = ${item.price * item.quantity}
          <button onClick={() => dispatch({
            type: 'REMOVE_ITEM',
            payload: item.id
          })}>
            Remove
          </button>
        </div>
      ))}

      <p>Total: ${state.total}</p>
      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
        Clear Cart
      </button>
    </div>
  );
}

Step 5: useMemo / useCallback - Performance Optimization

useMemo - Memoizing Computed Values

// src/components/ExpensiveCalculation.jsx
import { useState, useMemo } from 'react';

function slowFunction(num) {
  console.log('Calculating...');
  for (let i = 0; i < 1000000000; i++) {} // Heavy computation
  return num * 2;
}

export default function ExpensiveCalculation() {
  const [number, setNumber] = useState(0);
  const [dark, setDark] = useState(false);

  // Only recalculate when number changes
  const doubledNumber = useMemo(() => {
    return slowFunction(number);
  }, [number]);

  const theme = {
    backgroundColor: dark ? '#333' : '#fff',
    color: dark ? '#fff' : '#333'
  };

  return (
    <div style={theme}>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(parseInt(e.target.value) || 0)}
      />
      <p>Result: {doubledNumber}</p>
      <button onClick={() => setDark(!dark)}>
        Toggle Theme
      </button>
    </div>
  );
}

useCallback - Memoizing Functions

// src/components/CallbackExample.jsx
import { useState, useCallback, memo } from 'react';

// Memoized child component
const SearchButton = memo(({ onClick }) => {
  console.log('SearchButton rendered');
  return <button onClick={onClick}>Search</button>;
});

export default function CallbackExample() {
  const [query, setQuery] = useState('');
  const [count, setCount] = useState(0);

  // Only create new function when query changes
  const handleSearch = useCallback(() => {
    console.log(`Searching: ${query}`);
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search term"
      />
      <SearchButton onClick={handleSearch} />

      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment (SearchButton won't re-render)
      </button>
    </div>
  );
}

Step 6: Custom Hooks - Logic Reuse

useLocalStorage

// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

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

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

  return [value, setValue];
}

useFetch

// src/hooks/useFetch.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    const abortController = new AbortController();

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, {
          signal: abortController.signal
        });
        if (!response.ok) throw new Error('Network error');
        const json = await response.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

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

  return { data, loading, error };
}

useDebounce

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

Custom Hook Usage Example

// src/components/SearchWithDebounce.jsx
import { useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import { useFetch } from '../hooks/useFetch';

export default function SearchWithDebounce() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 500);

  const { data, loading, error } = useFetch(
    debouncedSearch
      ? `https://api.github.com/search/users?q=${debouncedSearch}`
      : null
  );

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search GitHub users..."
      />

      {loading && <p>Searching...</p>}
      {error && <p>Error: {error}</p>}
      {data?.items?.map(user => (
        <div key={user.id}>
          <img src={user.avatar_url} alt="" width={32} />
          {user.login}
        </div>
      ))}
    </div>
  );
}

Best Practices

1. useState Tips
   - Keep state minimal
   - Derive values through computation, not useState
   - Use spread operator when updating objects

2. useEffect Tips
   - Set dependency arrays accurately
   - Don't forget cleanup functions
   - Watch out for infinite loops

3. Performance Optimization
   - Avoid over-optimization
   - Measure before optimizing
   - Only use memo, useMemo, useCallback where needed

4. Custom Hooks
   - Use for logic reuse
   - Names must start with "use"
   - Follow single responsibility principle

Summary

React Hooks enable function components to handle state management and side effects. After mastering useState and useEffect basics, tackle complex state management with useContext and useReducer, then optimize performance with useMemo and useCallback.

← Back to list