DEV Community

Cover image for Advanced Hooks & State Management Patterns in React
Kushang Tailor
Kushang Tailor

Posted on

Advanced Hooks & State Management Patterns in React

Read Time: ~14 minutes | Building on React fundamentals to master state management at scale

Prerequisites: Familiarity with React basics, useState, useEffect (Part 1)


πŸ”— Series Navigation

← Part 1: Complete Guide from Zero to Production

Part 2: Advanced Hooks & State Management ← YOU ARE HERE

β†’ Part 3: Performance Optimization (coming next)


πŸ“Œ What You'll Learn

By the end of this guide, you'll understand:

  • βœ… Creating powerful custom hooks
  • βœ… When and how to use useReducer
  • βœ… Managing state globally with Context API
  • βœ… Redux fundamentals and when to use it
  • βœ… Modern alternatives: Zustand and Jotai
  • βœ… Choosing the right pattern for your project
  • βœ… Real-world shopping cart implementation

🎣 Custom Hooks: Reusing Logic Across Components

Custom hooks are regular JavaScript functions that let you extract component logic into reusable functions. They're one of the most powerful React patterns.

Rule #1: Custom Hooks Must Start with "use"

// βœ… Correct - starts with "use"
function useFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);

  return {
    value,
    bind: {
      value,
      onChange: e => setValue(e.target.value)
    },
    reset: () => setValue(initialValue)
  };
}

// ❌ Wrong - doesn't start with "use"
function formInput(initialValue) { ... }
Enter fullscreen mode Exit fullscreen mode

Example #1: useFormInput Hook

import { useState } from 'react';

function useFormInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);

  return {
    value,
    setValue,
    bind: {
      value,
      onChange: e => setValue(e.target.value)
    },
    reset: () => setValue(initialValue)
  };
}

// Using the custom hook
function LoginForm() {
  const email = useFormInput('');
  const password = useFormInput('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(email.value, password.value);
    email.reset();
    password.reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input {...email.bind} placeholder="Email" type="email" />
      <input {...password.bind} placeholder="Password" type="password" />
      <button type="submit">Login</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it's powerful: Logic is extracted once, reused everywhere. Need form input validation? Add it once to the hook.


Example #2: useFetch Hook - Data Loading Pattern

This is one of the most practical custom hooks for real projects:

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    let isMounted = true; // Prevent memory leaks

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, options);
        if (!response.ok) throw new Error(`Error: ${response.status}`);

        const json = await response.json();
        if (isMounted) setData(json);
      } catch (err) {
        if (isMounted) setError(err.message);
      } finally {
        if (isMounted) setLoading(false);
      }
    };

    fetchData();
    return () => { isMounted = false; }; // Cleanup
  }, [url, options]);

  return { data, loading, error };
}

// Using it
function ProductList() {
  const { data: products, loading, error } = useFetch('/api/products');

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

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key insight: The component doesn't need to know about fetch logicβ€”it just uses the hook.


Example #3: useLocalStorage Hook - Persistence Pattern

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

// Using it - Dark mode preference persists across page reloads
function App() {
  const [isDarkMode, setIsDarkMode] = useLocalStorage('darkMode', false);

  return (
    <div className={isDarkMode ? 'dark' : 'light'}>
      <button onClick={() => setIsDarkMode(!isDarkMode)}>
        Toggle Dark Mode
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ”„ useReducer: Complex State Logic

When you have multiple state variables that depend on each other, useReducer is cleaner than useState.

useReducer vs useState

Scenario Best Choice
Single simple value (count, toggle) useState
Multiple related values useReducer
State depends on previous state useReducer
Complex state transitions useReducer
Need to pass dispatch to children useReducer

Complete useReducer Example: Todo App

import { useReducer } from 'react';

// Reducer function - pure, testable
function todoReducer(state, action) {
  switch(action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, {
          id: Date.now(),
          text: action.payload,
          completed: false
        }]
      };

    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };

    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };

    case 'SET_FILTER':
      return { ...state, filter: action.payload };

    default:
      return state;
  }
}

function TodoApp() {
  const initialState = {
    todos: [],
    filter: 'all' // all, active, completed
  };

  const [state, dispatch] = useReducer(todoReducer, initialState);

  const filteredTodos = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.completed;
    if (state.filter === 'completed') return todo.completed;
    return true;
  });

  return (
    <div>
      <input
        onKeyPress={(e) => {
          if (e.key === 'Enter') {
            dispatch({ type: 'ADD_TODO', payload: e.target.value });
            e.target.value = '';
          }
        }}
        placeholder="Add a todo..."
      />

      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch({ 
                type: 'TOGGLE_TODO', 
                payload: todo.id 
              })}
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none'
            }}>
              {todo.text}
            </span>
            <button onClick={() => dispatch({ 
              type: 'DELETE_TODO', 
              payload: todo.id 
            })}>
              Delete
            </button>
          </li>
        ))}
      </ul>

      <div>
        {['all', 'active', 'completed'].map(f => (
          <button
            key={f}
            onClick={() => dispatch({ type: 'SET_FILTER', payload: f })}
            style={{
              fontWeight: state.filter === f ? 'bold' : 'normal'
            }}
          >
            {f.charAt(0).toUpperCase() + f.slice(1)}
          </button>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why useReducer wins here:

  • All state changes go through dispatchβ€”easy to debug and test
  • Reducer is a pure functionβ€”predictable and testable
  • Complex state logic stays organized in one place

🌍 Context API: Global State Without Libraries

Context API lets you share state across components without prop drilling.

Problem: Prop Drilling

// ❌ Passing props through 5 levels is messy
<App theme={theme} />
  <Header theme={theme} />
    <Nav theme={theme} />
      <Button theme={theme} />
Enter fullscreen mode Exit fullscreen mode

Solution: Context API

import { createContext, useContext, useState } from 'react';

// Step 1: Create context
const ThemeContext = createContext();

// Step 2: Create provider component
export function ThemeProvider({ children }) {
  const [isDark, setIsDark] = useState(false);

  const value = {
    isDark,
    toggleTheme: () => setIsDark(prev => !prev),
    colors: isDark
      ? { bg: '#1a1a1a', text: '#ffffff' }
      : { bg: '#ffffff', text: '#000000' }
  };

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

// Step 3: Create custom hook to use context
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

// Step 4: Wrap your app
function App() {
  return (
    <ThemeProvider>
      <Header />
      <MainContent />
      <Footer />
    </ThemeProvider>
  );
}

// Step 5: Use anywhere
function Button() {
  const { isDark, toggleTheme, colors } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      style={{ background: colors.bg, color: colors.text }}
    >
      {isDark ? 'β˜€οΈ Light' : 'πŸŒ™ Dark'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to use Context API:

  • βœ… Theme settings (light/dark mode)
  • βœ… User authentication state
  • βœ… Language/locale preferences
  • ❌ Frequently changing data (performance issues)

πŸ“¦ Redux: Enterprise-Grade State Management

Redux is a predictable state container. It's overkill for small apps but essential for large ones.

Redux Flow

Action β†’ Reducer β†’ Store β†’ Components Update
Enter fullscreen mode Exit fullscreen mode

Complete Redux Example: Shopping Cart

import { createStore } from 'redux';

// Step 1: Define initial state
const initialState = {
  items: [],
  total: 0,
  loading: false
};

// Step 2: Create reducer
function cartReducer(state = initialState, 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 removedItem = state.items.find(
        item => item.id === action.payload
      );
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
        total: state.total - (removedItem.price * removedItem.quantity)
      };

    case 'CLEAR_CART':
      return initialState;

    default:
      return state;
  }
}

// Step 3: Create store
const store = createStore(cartReducer);

// Step 4: Dispatch actions
store.dispatch({
  type: 'ADD_ITEM',
  payload: { id: 1, name: 'Laptop', price: 1200 }
});

console.log(store.getState());
// { items: [{id: 1, name: 'Laptop', price: 1200, quantity: 1}], total: 1200 }
Enter fullscreen mode Exit fullscreen mode

Redux Pros & Cons:

  • βœ… Predictable state changes
  • βœ… Time-travel debugging (Redux DevTools)
  • βœ… Great for large, complex apps
  • ❌ Boilerplate code (actions, reducers, selectors)
  • ❌ Steep learning curve

⚑ Zustand: Lightweight Alternative to Redux

Zustand is simpler than Redux with minimal boilerplate.

import { create } from 'zustand';

// Create store in one place
const useCartStore = create((set) => ({
  items: [],
  total: 0,

  // Actions
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
    total: state.total + item.price
  })),

  removeItem: (id) => set((state) => {
    const removedItem = state.items.find(item => item.id === id);
    return {
      items: state.items.filter(item => item.id !== id),
      total: state.total - removedItem.price
    };
  }),

  clearCart: () => set({
    items: [],
    total: 0
  })
}));

// Use anywhere - no Provider needed!
function ShoppingCart() {
  const { items, total, removeItem } = useCartStore();

  return (
    <div>
      <h2>Cart: ${total}</h2>
      {items.map(item => (
        <div key={item.id}>
          {item.name} - ${item.price}
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why Zustand is Awesome:

  • βœ… No Provider wrapper needed
  • βœ… Minimal boilerplate
  • βœ… Great performance
  • βœ… TypeScript friendly
  • βœ… Smaller bundle size

🎯 Jotai: Atom-Based State Management

Jotai takes a different approachβ€”primitive atoms instead of a single store.

import { atom, useAtom } from 'jotai';

// Create atoms (primitive pieces of state)
const cartItemsAtom = atom([]);
const cartTotalAtom = atom(0);

// Derived atom (computed from other atoms)
const cartCountAtom = atom(
  (get) => get(cartItemsAtom).length
);

// Use atoms in components
function ShoppingCart() {
  const [items, setItems] = useAtom(cartItemsAtom);
  const [total, setTotal] = useAtom(cartTotalAtom);
  const [count] = useAtom(cartCountAtom);

  const addItem = (item) => {
    setItems([...items, item]);
    setTotal(total + item.price);
  };

  return (
    <div>
      <h2>Cart ({count} items): ${total}</h2>
      <button onClick={() => addItem({ name: 'Laptop', price: 1200 })}>
        Add Laptop
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Jotai Benefits:

  • βœ… Fine-grained reactivity
  • βœ… Only components using changed atoms re-render
  • βœ… Great for complex interdependent state
  • βœ… No Provider overhead

πŸ›’ Real-World Example: Shopping Cart with Multiple Patterns

Let's build a shopping cart using Context API + useReducer (the practical combination):

import { createContext, useContext, useReducer } from 'react';

// 1. Create context
const CartContext = createContext();

// 2. Reducer
function cartReducer(state, action) {
  switch(action.type) {
    case 'ADD_TO_CART': {
      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
          )
        };
      }

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

    case 'REMOVE_FROM_CART':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };

    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ).filter(item => item.quantity > 0)
      };

    case 'CLEAR_CART':
      return { ...state, items: [] };

    default:
      return state;
  }
}

// 3. Provider component
export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  const total = state.items.reduce(
    (sum, item) => sum + (item.price * item.quantity),
    0
  );

  const value = {
    items: state.items,
    total,
    addToCart: (product) => dispatch({ type: 'ADD_TO_CART', payload: product }),
    removeFromCart: (id) => dispatch({ type: 'REMOVE_FROM_CART', payload: id }),
    updateQuantity: (id, quantity) => dispatch({
      type: 'UPDATE_QUANTITY',
      payload: { id, quantity }
    }),
    clearCart: () => dispatch({ type: 'CLEAR_CART' })
  };

  return (
    <CartContext.Provider value={value}>
      {children}
    </CartContext.Provider>
  );
}

// 4. Custom hook
export function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within CartProvider');
  }
  return context;
}

// 5. Usage
function ProductCard({ product }) {
  const { addToCart } = useCart();

  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => addToCart(product)}>
        Add to Cart
      </button>
    </div>
  );
}

function Cart() {
  const { items, total, removeFromCart, updateQuantity } = useCart();

  return (
    <div>
      <h2>Shopping Cart</h2>
      {items.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <>
          {items.map(item => (
            <div key={item.id}>
              <h4>{item.name}</h4>
              <input
                type="number"
                value={item.quantity}
                onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
              />
              <p>${item.price * item.quantity}</p>
              <button onClick={() => removeFromCart(item.id)}>Remove</button>
            </div>
          ))}
          <h3>Total: ${total.toFixed(2)}</h3>
        </>
      )}
    </div>
  );
}

// 6. App structure
function App() {
  return (
    <CartProvider>
      <ProductCard product={{ id: 1, name: 'Laptop', price: 1200 }} />
      <Cart />
    </CartProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

🎯 Choosing Your State Management Tool

Decision Tree

Is your app small (< 5 components)?
β”œβ”€ Yes β†’ Use useState directly
└─ No β†’ Continue

Do you need to share state between many components?
β”œβ”€ Yes β†’ Continue
└─ No β†’ Use useState + prop drilling

Is the state changing frequently (> 10 times/sec)?
β”œβ”€ Yes β†’ Use Jotai or Zustand
└─ No β†’ Continue

Is your team familiar with Redux?
β”œβ”€ Yes β†’ Consider Redux
└─ No β†’ Consider Zustand

Do you need middleware (logging, analytics)?
β”œβ”€ Yes β†’ Use Redux
└─ No β†’ Use Zustand or Context API
Enter fullscreen mode Exit fullscreen mode

Quick Comparison

Tool Best For Learning Curve Bundle Size
useState Simple state Easiest 0 KB
useReducer Complex state Easy 0 KB
Context API Theme, auth Easy 0 KB
Redux Large apps Hard 10 KB
Zustand Medium apps Easy 2 KB
Jotai Granular updates Medium 5 KB

πŸŽ“ Performance Considerations

Context API Performance Pitfall

// ❌ BAD - Entire app re-renders on state change
function ThemeProvider({ children }) {
  const [isDark, setIsDark] = useState(false);

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

// βœ… GOOD - Memoize context value
function ThemeProvider({ children }) {
  const [isDark, setIsDark] = useState(false);

  const value = useMemo(() => ({ isDark, setIsDark }), [isDark]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key insight: Zustand and Jotai automatically optimize this. Redux requires careful selector use.


πŸš€ Advanced Pattern: Combining Approaches

Real projects often use multiple state management tools:

// Global app state β†’ Redux/Zustand
// Theme & Auth β†’ Context API
// UI state (modals, dropdowns) β†’ useState
// Form data β†’ Custom useForm hook
Enter fullscreen mode Exit fullscreen mode

This is normal and healthyβ€”don't over-engineer.


πŸ’‘ Final Thoughts: State Management Maturity

Beginner Phase

  • Use useState everywhere
  • Props for simple sharing
  • useReducer when state gets complex

Intermediate Phase

  • Context API for theme, auth, user
  • useReducer for complex components
  • Custom hooks for reusable logic

Advanced Phase

  • Zustand for app state
  • Context for cross-cutting concerns
  • Jotai for highly granular updates
  • Redux only if you have 50+ components

πŸ“š Key Takeaways

  1. Custom hooks are the foundationβ€”master these first
  2. useReducer scales better than useState for complex state
  3. Context API is perfect for theme/auth, not data caches
  4. Zustand is the modern, simple alternative to Redux
  5. Jotai excels at fine-grained reactivity
  6. Choose based on app size, not hype

πŸ”— Quick Resources


πŸ’¬ What's Your State Management Journey?

Have you struggled with prop drilling? Built a custom hook? Migrated from Redux? Share your experience in the commentsβ€”I'd love to hear what state management patterns your team uses!


πŸ“– Series Roadmap

← Part 1: Complete Guide from Zero to Production

Part 2: Advanced Hooks & State Management Patterns ← YOU ARE HERE

β†’ [Part 3: Performance Optimization & Advanced Patterns] (coming-soon in 2 weeks)

Next in Part 3:

  • useMemo & useCallback deep dive
  • Code splitting and lazy loading
  • Suspense and React.lazy()
  • Performance profiling with DevTools
  • Real optimization case studies

Happy state managing! πŸš€

Top comments (0)