DEV Community

Cover image for React Hooks in 2026: Not Just What They Do — When to Reach for Each One
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

React Hooks in 2026: Not Just What They Do — When to Reach for Each One

Every hook is a solution to a specific problem. The senior developer's skill isn't knowing the API — it's knowing the problem.


Let me tell you the moment I knew I'd finally understood React Hooks.

It was 2019; I was building the first mobile app from scratch. No templates. No prior codebase. A blank React Native project and a deadline that didn't care about my learning curve.

I had a form with six steps. Each step had its own state. State from step two affected validation in step five. The user could jump backwards. Progress had to persist. Errors had to be per-field, not per-form.

My first instinct was to reach for useState. Six calls, one per step, plus more for errors, plus more for the current step index. By the time I had a working draft, I had thirteen useState calls that couldn't talk to each other without me writing the coordination logic manually.

I stared at it for a long time. Then I deleted all of it and wrote one useReducer.

Everything became cleaner. The state transitions were explicit. The debugging was straightforward. The tests were obvious. The next developer who opened that file — including future me, three months later — could understand the form's state machine in five minutes.

That's when I understood the real question. Not "What does useReducer do?" — I knew that from the docs. The real question was: "how do I know I need useReducer instead of useState?" And that question doesn't live in any documentation.


Before We Start: The One Question That Changes Everything

Before reaching for any hook, ask: "What changes, and what needs to respond to that change?"

This sounds simple. It isn't. Most over-engineered React components exist because someone added a hook first and figured out the problem second. The hook then created its own complexity, which required another hook, which created more complexity.

The question forces you to define the problem before you pick the tool. A few minutes spent answering it correctly saves hours of debugging the wrong abstraction.

Let me show you what I mean as we go through each hook.


useState — The Entry Point and Its Limits

What changes: A single, independent piece of data within one component.

"If this value changes, does anything else in this component need to know about it or react to it?"

If the answer is "no" — useState is correct. If the answer is "yes, something else changes too" — you might be building a state machine that needs useReducer.

// useState is correct here:
// The count changes. Nothing else in the component needs to respond to that change.
// It's truly independent.

function LikeButton({ postId }: { postId: string }) {
  const [isLiked, setIsLiked] = useState(false);
  const [likeCount, setLikeCount] = useState(0);

  const handleLike = async () => {
    // Functional update form — non-negotiable when new state depends on previous
    // Without this, rapid clicks create stale closure bugs:
    // you click twice fast, both callbacks read isLiked = false,
    // both set it to true — the second click doesn't unlike
    setIsLiked(prev => !prev);
    setLikeCount(prev => prev + (isLiked ? -1 : 1));
    await toggleLike(postId);
  };

  return (
    <button onClick={handleLike}>
      {isLiked ? '❤️' : '🤍'} {likeCount}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Where useState starts to struggle:

// 🚩 The smell: multiple useState calls that always change together
// If you find yourself writing this, you're managing a state machine manually

function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [formData, setFormData] = useState<FormData>({});
  const [isValid, setIsValid] = useState(false);

  // Now every handler needs to coordinate all five of these.
  // Welcome to the coordination tax.
  const handleNext = () => {
    // Validate current step
    const newErrors = validateStep(step, formData);
    setErrors(newErrors);
    setIsValid(Object.keys(newErrors).length === 0);
    if (Object.keys(newErrors).length === 0) {
      setStep(prev => prev + 1);
    }
  };
  // This is already fragile and we haven't written the submit handler yet.
}
Enter fullscreen mode Exit fullscreen mode

When you see multiple pieces of state that always change together, or when one state change conditionally triggers another — that's useReducer territory.


useReducer — State Machines, Not Just Complex State

What changes: Multiple related pieces of state with explicit transitions between them.

"Can I describe this component's state as a set of named states with named transitions between them?"

If yes — useReducer. The reducer IS the state machine definition.

// Six steps of state. Now it's one reducer.
// Every possible state transition is named and explicit.

type FormStep = 1 | 2 | 3 | 4 | 5 | 6;

interface FormState {
  currentStep: FormStep;
  data: Partial<FormData>;
  errors: Record<string, string>;
  isSubmitting: boolean;
  isComplete: boolean;
}

type FormAction =
  | { type: 'NEXT_STEP'; payload: { stepData: Partial<FormData>; errors: Record<string, string> } }
  | { type: 'PREV_STEP' }
  | { type: 'UPDATE_FIELD'; payload: { field: string; value: unknown } }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; payload: string };

const initialFormState: FormState = {
  currentStep: 1,
  data: {},
  errors: {},
  isSubmitting: false,
  isComplete: false,
};

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'NEXT_STEP': {
      // State transition is explicit: you can only go to next step if no errors
      const hasErrors = Object.keys(action.payload.errors).length > 0;
      if (hasErrors) {
        return { ...state, errors: action.payload.errors };
      }
      return {
        ...state,
        currentStep: Math.min(6, state.currentStep + 1) as FormStep,
        data: { ...state.data, ...action.payload.stepData },
        errors: {},
      };
    }

    case 'PREV_STEP':
      return {
        ...state,
        currentStep: Math.max(1, state.currentStep - 1) as FormStep,
        errors: {}, // Clear errors when going back — intentional UX decision
      };

    case 'UPDATE_FIELD':
      return {
        ...state,
        data: { ...state.data, [action.payload.field]: action.payload.value },
        // Clear the error for this field as the user types — live validation
        errors: { ...state.errors, [action.payload.field]: '' },
      };

    case 'SUBMIT_START':
      return { ...state, isSubmitting: true, errors: {} };

    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false, isComplete: true };

    case 'SUBMIT_ERROR':
      return {
        ...state,
        isSubmitting: false,
        // Show a general error at the current step
        errors: { form: action.payload },
      };

    default:
      return state;
  }
}

// The component is now clean.
// All the coordination logic is in the reducer — testable in isolation.
function MultiStepForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const handleNext = () => {
    const stepData = getCurrentStepData(state.currentStep, state.data);
    const errors = validateStep(state.currentStep, stepData);
    dispatch({ type: 'NEXT_STEP', payload: { stepData, errors } });
  };

  return (
    <div>
      <StepIndicator current={state.currentStep} total={6} />
      <StepContent
        step={state.currentStep}
        data={state.data}
        errors={state.errors}
        onFieldChange={(field, value) =>
          dispatch({ type: 'UPDATE_FIELD', payload: { field, value } })
        }
      />
      <FormNavigation
        onNext={handleNext}
        onBack={() => dispatch({ type: 'PREV_STEP' })}
        isSubmitting={state.isSubmitting}
        currentStep={state.currentStep}
        totalSteps={6}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The test that tells you if your reducer is well-designed: Can you test every state transition without mounting a component? If yes — you've separated your logic from your UI correctly. The reducer is pure. The component is dumb.

// Test the reducer in isolation — no React, no render, no DOM
describe('formReducer', () => {
  it('does not advance to next step when errors exist', () => {
    const state = formReducer(initialFormState, {
      type: 'NEXT_STEP',
      payload: {
        stepData: {},
        errors: { name: 'Name is required' },
      },
    });

    expect(state.currentStep).toBe(1); // Stayed on step 1
    expect(state.errors.name).toBe('Name is required');
  });

  it('clears errors when going back', () => {
    const stateWithErrors: FormState = {
      ...initialFormState,
      currentStep: 2,
      errors: { email: 'Invalid email' },
    };

    const state = formReducer(stateWithErrors, { type: 'PREV_STEP' });
    expect(state.errors).toEqual({});
    expect(state.currentStep).toBe(1);
  });
});
Enter fullscreen mode Exit fullscreen mode

useEffect — The Most Misused Hook in React

What changes: Something outside React's rendering model needs to synchronize with your component's state.

"Am I synchronizing with something external — DOM, API, timer, subscription, browser API? Or am I just computing a value?"

If you're computing a value — don't use useEffect. Compute it inline or use useMemo. useEffect is for synchronization, not computation.

// 🚩 The most common useEffect mistake: using it for derived state
function UserGreeting({ user }: { user: User }) {
  const [greeting, setGreeting] = useState('');

  // Wrong: This is not synchronization. It's computation.
  // Every render: compute greeting → trigger re-render → compute greeting again
  // That's a render cycle for zero reason.
  useEffect(() => {
    setGreeting(`Hello, ${user.name.split(' ')[0]}!`);
  }, [user.name]);

  return <h1>{greeting}</h1>;
}

// ✅ Correct: Compute inline. No useEffect needed.
function UserGreeting({ user }: { user: User }) {
  const greeting = `Hello, ${user.name.split(' ')[0]}!`;
  return <h1>{greeting}</h1>;
}
Enter fullscreen mode Exit fullscreen mode
// ✅ useEffect is correct here: synchronizing with an external system (browser API)
function useDocumentTitle(title: string) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;

    // Cleanup: restore the previous title when component unmounts
    // or when title changes. This is the cleanup function doing real work.
    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

// ✅ useEffect is correct here: subscribing to an external event
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // Cleanup: remove listeners when component unmounts.
    // Without this, the handlers remain attached to the window
    // even after the component is gone — a memory leak.
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []); // Empty array: subscribe once on mount, unsubscribe on unmount

  return isOnline;
}
Enter fullscreen mode Exit fullscreen mode

The AbortController pattern — the one most tutorials skip:

// Data fetching in useEffect — the complete, production-safe version
function useUserData(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // AbortController lets us cancel the request if:
    // 1. The component unmounts before the request completes
    // 2. userId changes before the first request completes
    // Without this, a fast userId change causes a race condition:
    // request for user A resolves AFTER request for user B,
    // and you render user A's data for user B's profile.
    const controller = new AbortController();

    const fetchUser = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal, // Link this request to the AbortController
        });

        if (!response.ok) throw new Error('Failed to fetch');

        const data: User = await response.json();
        setUser(data);
      } catch (err) {
        // AbortError is expected and not a real error — the component
        // unmounted or userId changed. Don't show an error for this.
        if (err instanceof Error && err.name === 'AbortError') return;
        setError(err instanceof Error ? err.message : 'Unknown error');
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();

    // Cleanup: abort the in-flight request
    return () => controller.abort();
  }, [userId]);

  return { user, isLoading, error };
}
Enter fullscreen mode Exit fullscreen mode

useContext — Sharing State Without Prop Drilling

The diagnostic question: "Is this value needed by components that are far apart in the tree AND changes infrequently?"

Both conditions matter. useContext is efficient for infrequently changing values. For highly dynamic state that changes on every keystroke, it causes performance issues — every consumer re-renders when the context value changes.

// ✅ Good useContext usage: theme changes rarely, needed everywhere
// ThemeContext.tsx

type Theme = 'light' | 'dark';

interface ThemeContextValue {
  theme: Theme;
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>(() => {
    // Initialize from localStorage — the inline script handles first paint
    try {
      return (localStorage.getItem('theme') as Theme) ?? 'light';
    } catch {
      return 'light';
    }
  });

  const toggleTheme = useCallback(() => {
    setTheme(prev => {
      const next = prev === 'light' ? 'dark' : 'light';
      try { localStorage.setItem('theme', next); } catch {}
      document.documentElement.setAttribute('data-theme', next);
      return next;
    });
  }, []);

  // Memoize the context value to prevent unnecessary re-renders of all consumers
  // when the ThemeProvider's parent re-renders but theme hasn't changed
  const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);

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

// Always wrap useContext in a custom hook — two reasons:
// 1. Throws a clear error if used outside the provider (beats a cryptic undefined error)
// 2. Hides the Context implementation — consumers don't need to import ThemeContext
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
}
Enter fullscreen mode Exit fullscreen mode
// 🚩 useContext performance trap: high-frequency updates in context
// This causes EVERY consumer to re-render on every keystroke

const SearchContext = createContext<{ query: string; setQuery: (q: string) => void } | null>(null);

function SearchProvider({ children }: { children: ReactNode }) {
  const [query, setQuery] = useState('');
  // Every time query changes (every keystroke), ALL consumers re-render.
  // If there are 20 components consuming SearchContext, that's 20 re-renders per keystroke.
  return (
    <SearchContext.Provider value={{ query, setQuery }}>
      {children}
    </SearchContext.Provider>
  );
}

// ✅ Better: keep high-frequency state local, pass callbacks up deliberately
// Or use a state management library (Zustand) that supports selective subscriptions
Enter fullscreen mode Exit fullscreen mode

useCallback and useMemo — Performance Tools, Not Default Habits

I've seen codebases where every function was wrapped in useCallback "for performance." The profiler showed zero improvement in re-render counts. What it showed was slightly longer render times — from the dependency array comparisons on every render.

"Is this function passed as a prop to a component wrapped in React.memo? Or used as a dependency in a useEffect?"

If neither — useCallback adds overhead with no benefit.

"Is this computation genuinely expensive (sorting thousands of items, complex math), AND does it run on every render?"

If the computation is cheap (string concatenation, simple filtering of small arrays) — useMemo adds overhead with no benefit.

// 🚩 useCallback that helps nothing
function ParentComponent() {
  const [count, setCount] = useState(0);

  // This function is memoized — but Child is not wrapped in React.memo.
  // React.memo is what makes stable references matter.
  // Without it, Child re-renders whenever Parent re-renders regardless.
  // You're paying the memoization cost for zero benefit.
  const handleClick = useCallback(() => {
    console.log('clicked', count);
  }, [count]);

  return <Child onClick={handleClick} />; // Child is NOT React.memo
}

// ✅ useCallback that earns its place
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
  // React.memo: only re-render if props change reference.
  // Now the stable reference from useCallback actually prevents re-renders.
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
});

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [unrelated, setUnrelated] = useState(0);

  // useCallback is justified here:
  // - Child is wrapped in React.memo
  // - stable reference → Child doesn't re-render when `unrelated` changes
  const handleClick = useCallback(() => {
    console.log('clicked', count);
  }, [count]);

  return (
    <div>
      <button onClick={() => setUnrelated(u => u + 1)}>
        Change unrelated ({unrelated})
      </button>
      <Child onClick={handleClick} />
      {/* Child does NOT re-render when unrelated changes */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// useMemo: the right use case
function ProductList({ products, filters }: { products: Product[]; filters: Filters }) {
  // Filtering 10,000 products on every render is expensive.
  // useMemo ensures this only runs when products or filters actually change.
  const filteredProducts = useMemo(() => {
    return products
      .filter(p => !filters.category || p.category === filters.category)
      .filter(p => p.price >= filters.minPrice && p.price <= filters.maxPrice)
      .sort((a, b) => {
        if (filters.sortBy === 'price') return a.price - b.price;
        return a.name.localeCompare(b.name);
      });
  }, [products, filters]); // Only recalculate when these change

  return (
    <div>
      {filteredProducts.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The rule: profile first. Optimize second. React DevTools Profiler shows you which components re-render and how expensive they are. Every optimization decision should be backed by a profile, not a feeling.


useRef — Escaping React Without Losing Your Mind

What changes: Something that needs to persist between renders but should NOT trigger a re-render when it changes.

"Do I need to access a DOM element directly, OR do I need to store a mutable value that shouldn't cause re-renders?"

// Use case 1: DOM access
function VideoPlayer({ src }: { src: string }) {
  const videoRef = useRef<HTMLVideoElement>(null);

  const handlePlay = () => {
    // Direct DOM access — can't be done with state
    videoRef.current?.play();
  };

  const handlePause = () => {
    videoRef.current?.pause();
  };

  return (
    <div>
      <video ref={videoRef} src={src} />
      <button onClick={handlePlay}>Play</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  );
}

// Use case 2: Storing a mutable value without re-render
function useInterval(callback: () => void, delay: number) {
  const savedCallback = useRef(callback);

  // Keep the ref up to date without causing re-renders
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    const id = setInterval(() => savedCallback.current(), delay);
    // Cleanup: clear the interval when the component unmounts or delay changes
    return () => clearInterval(id);
  }, [delay]);
}

// Use case 3: Tracking previous values
function usePreview<T>(value: T): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = value; // Store current value after render
  });

  return ref.current; // Return the value from the PREVIOUS render
}
Enter fullscreen mode Exit fullscreen mode

useTransition — Making the UI Lie (In the Best Way)

What changes: A state update that's expensive to compute, AND the user needs the input to remain responsive.

"Is there a fast interaction (typing, clicking) that triggers a slow render? And is it okay to show the old UI briefly while the new one computes?"

This is where the mental model matters. useTransition doesn't make the computation faster. It makes React deprioritize it — keeping the input responsive while the heavy update runs in the background. The UI briefly shows "stale" data, which is usually fine.

// Without useTransition: typing in the search input feels laggy
// because every keystroke triggers an expensive filter of 50,000 items
// and the browser hangs until it's done.

// With useTransition: typing is instant. The results update a moment later.
// The user sees smooth input. The results follow. That's the right UX.

function ProductSearch({ products }: { products: Product[] }) {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // This update is instant — input stays responsive
    setInputValue(e.target.value);

    // This update is a transition — React can deprioritize it
    // if there are more urgent updates (like the next keystroke)
    startTransition(() => {
      setSearchQuery(e.target.value);
    });
  };

  // This expensive computation only runs when searchQuery changes
  // (which is deferred), not when inputValue changes (which is instant)
  const results = useMemo(() => {
    if (!searchQuery) return products;
    return products.filter(p =>
      p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
      p.description.toLowerCase().includes(searchQuery.toLowerCase())
    );
  }, [products, searchQuery]);

  return (
    <div>
      <input
        value={inputValue}
        onChange={handleChange}
        placeholder="Search products..."
      />
      {/* isPending = the deferred update is still computing */}
      {/* Don't use a full loading spinner here — it's jarring for fast transitions */}
      {/* A subtle opacity change communicates "updating" without disrupting the user */}
      <div style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 0.1s' }}>
        <p>{results.length} results</p>
        {results.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The difference between useTransition and useDeferredValue:

Both solve the "fast input, slow render" problem. The difference is where you apply the optimization:

  • useTransition — you control when the expensive state update happens. Use when you own the state update.
  • useDeferredValue — you defer a value you received (from props or state). Use when you don't control the update.
// useDeferredValue: when you receive a value from outside and can't control when it changes
function SearchResults({ query }: { query: string }) {
  // query comes from a parent — we don't control when it changes.
  // useDeferredValue gives us a version of query that React will
  // deprioritize updating, keeping the parent's UI responsive.
  const deferredQuery = useDeferredValue(query);

  const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);

  // isStale = the deferred value hasn't caught up to the real value yet
  const isStale = query !== deferredQuery;

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {results.map(r => <SearchResult key={r.id} result={r} />)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useOptimistic — Telling the User "Done" Before It Is

What changes: The UI should reflect a user action immediately — before the server confirms it.

"If the server call takes 1 second, should the user wait for the UI to update, or should the UI update immediately and roll back if it fails?"

Almost always the answer is "update immediately." Waiting for server confirmation before showing feedback is a UX pattern from 2010. Modern users expect instant feedback.

// A like button that feels instant — because it is

interface Post {
  id: string;
  content: string;
  likeCount: number;
  isLikedByUser: boolean;
}

async function toggleLikeApi(postId: string, currentlyLiked: boolean): Promise<void> {
  const response = await fetch(`/api/posts/${postId}/like`, {
    method: currentlyLiked ? 'DELETE' : 'POST',
  });
  if (!response.ok) throw new Error('Failed to toggle like');
}

function LikeButton({ post }: { post: Post }) {
  const [actualPost, setActualPost] = useState(post);

  // useOptimistic takes:
  // 1. The actual state (what the server knows)
  // 2. A function to compute the optimistic state (what we show immediately)
  const [optimisticPost, setOptimisticLike] = useOptimistic(
    actualPost,
    (currentPost, isLiking: boolean) => ({
      ...currentPost,
      isLikedByUser: isLiking,
      likeCount: currentPost.likeCount + (isLiking ? 1 : -1),
    })
  );

  const handleLike = async () => {
    const isCurrentlyLiked = optimisticPost.isLikedByUser;

    // Step 1: Show the result immediately in the UI
    setOptimisticLike(!isCurrentlyLiked);

    try {
      // Step 2: Tell the server
      await toggleLikeApi(post.id, isCurrentlyLiked);

      // Step 3: Update the actual state to match
      setActualPost(prev => ({
        ...prev,
        isLikedByUser: !isCurrentlyLiked,
        likeCount: prev.likeCount + (isCurrentlyLiked ? -1 : 1),
      }));
    } catch {
      // Step 4 (on failure): useOptimistic automatically reverts to actualPost
      // The UI snaps back to the actual state. User sees a brief flash of the revert.
      // Show a toast notification explaining the failure.
      console.error('Like failed — UI reverted automatically');
    }
  };

  return (
    <button
      onClick={handleLike}
      aria-label={optimisticPost.isLikedByUser ? 'Unlike post' : 'Like post'}
    >
      {optimisticPost.isLikedByUser ? '❤️' : '🤍'} {optimisticPost.likeCount}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The mental model for useOptimistic: think of it as a temporary overlay on your real state. You apply the overlay immediately. The real state catches up when the server responds. If the server fails, the overlay is removed and you're back to real state. The "magic" is that you never have to write the revert logic — it's built in.


Custom Hooks — The Real Power Move

Everything above becomes dramatically more powerful when you extract it into custom hooks. A custom hook is just a function that uses hooks — but it's the architectural tool that makes React logic reusable, testable, and composable.

// usePostActions.ts
// Encapsulates all the logic for interacting with a post.
// The component that uses this doesn't know about API calls,
// optimistic state, error handling, or state management.
// It just calls functions and reads state.

interface UsePostActionsReturn {
  post: Post;
  handleLike: () => Promise<void>;
  handleBookmark: () => Promise<void>;
  isLiking: boolean;
  isBookmarking: boolean;
}

export function usePostActions(initialPost: Post): UsePostActionsReturn {
  const [actualPost, setActualPost] = useState(initialPost);
  const [isLiking, setIsLiking] = useState(false);
  const [isBookmarking, setIsBookmarking] = useState(false);

  const [optimisticPost, applyOptimistic] = useOptimistic(
    actualPost,
    (post, action: { type: 'like' | 'bookmark'; value: boolean }) => {
      if (action.type === 'like') {
        return {
          ...post,
          isLikedByUser: action.value,
          likeCount: post.likeCount + (action.value ? 1 : -1),
        };
      }
      return { ...post, isBookmarkedByUser: action.value };
    }
  );

  const handleLike = async () => {
    const newValue = !optimisticPost.isLikedByUser;
    setIsLiking(true);
    applyOptimistic({ type: 'like', value: newValue });
    try {
      await toggleLikeApi(initialPost.id, !newValue);
      setActualPost(prev => ({
        ...prev,
        isLikedByUser: newValue,
        likeCount: prev.likeCount + (newValue ? 1 : -1),
      }));
    } catch {
      // Optimistic state reverts automatically
    } finally {
      setIsLiking(false);
    }
  };

  const handleBookmark = async () => {
    const newValue = !optimisticPost.isBookmarkedByUser;
    setIsBookmarking(true);
    applyOptimistic({ type: 'bookmark', value: newValue });
    try {
      await toggleBookmarkApi(initialPost.id, !newValue);
      setActualPost(prev => ({ ...prev, isBookmarkedByUser: newValue }));
    } catch {
      // Optimistic state reverts automatically
    } finally {
      setIsBookmarking(false);
    }
  };

  return {
    post: optimisticPost,
    handleLike,
    handleBookmark,
    isLiking,
    isBookmarking,
  };
}

// The component is now trivially simple.
// All complexity is in the hook — testable without mounting a component.
function PostCard({ post }: { post: Post }) {
  const { post: postState, handleLike, handleBookmark } = usePostActions(post);

  return (
    <article>
      <p>{postState.content}</p>
      <button onClick={handleLike}>
        {postState.isLikedByUser ? '❤️' : '🤍'} {postState.likeCount}
      </button>
      <button onClick={handleBookmark}>
        {postState.isBookmarkedByUser ? '🔖' : '📄'}
      </button>
    </article>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here's the thing nobody says at the end of a hooks guide:

Most React performance problems I've seen in production are not solved by hooks. They're solved by moving state closer to where it's used.

The single most impactful architectural change in most React codebases isn't "add useCallback here" or "wrap this in useMemo."This state is being managed three levels up when it's only used one level down move it."

A state that lives too high in the tree re-renders too many components. Before reaching for useCallback "to prevent re-renders," ask, "Why does this parent re-render when this child's data changes?" The answer is usually "because the state is in the wrong place."

State colocation—keeping state as close as possible to where it's used—is the optimization that costs nothing and delivers the most. It's not a hook. It's a thinking habit.

Master the hooks. But don't forget the thinking habit.

  • Always remember > "The best code is no code at all." > — Jeff Atwood > > Why this stays with me: Every hook you add is code. The best React components are the ones that reach for the simplest hook that solves the problem — and stop there.

The Unfinished Chapter

Here's what I'm still working through:

The line between "use useOptimistic" and "use a proper state management solution with optimistic update support" is blurry to me in practice. For a simple case, a like button and a single field update useOptimistic is clean and sufficient. For complex cases, a multi-field form that updates a nested data structure with partial success handling, I've found myself reinventing things that libraries like Zustand or React Query handle better.

My current heuristic: if the optimistic update involves more than one data entity, or if failure recovery is complex, reach for a library. If it's a single action with a clear success/failure boundary, useOptimistic is enough.

But I've been wrong about this heuristic at least twice. Where do you draw the line?


I built the multi-step form six years ago. The useReducerrefactor was the moment hooks stopped being a feature I used and started being a way I thought.

That's what I want for you. Not memorization of the API. A change in how you think about problems before you write the first line. The diagnostic questions in this article are the questions I ask now automatically — not because I'm smart, but because I've shipped enough broken components to have learned which question to ask first.

Use the hook that fits the problem. Use the simplest hook that fits the problem. And know when not to use a hook at all.

Simple? Yes. Takes years to feel natural. Welcome to the craft.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book. Surrounded by AI

Top comments (0)