
If you have written a React class component, you know the feeling: a form component that starts as thirty lines and bloats into three hundred as you thread state through lifecycle methods, bind handlers, and wonder why a setState call is behaving unexpectedly. Hooks were built to kill that complexity. They let you use state and React features from plain functions, no classes, no this, no lifecycle gymnastics, and they have been the default way to write React since 16.8. Yet many developers still write class components or copy hook patterns without understanding the mental model, which is why their code mysteriously breaks.
This tutorial assumes you already know the basics of React components and JSX. What we are going to do is rebuild the mental model around hooks, starting from the two hooks you will use in almost every component, then moving to the ones that solve specific problems. By the end, you should not just be able to use hooks; you should be able to reason about why a hook behaves the way it does, and that is what separates fluent React developers from people who patch symptoms.
If you have written a React class component, you know the feeling: a form component that starts as thirty lines and bloats into three hundred as you thread state through lifecycle methods, bind handlers, and wonder why a setState call is behaving unexpectedly. Hooks were built to kill that complexity. They let you use state and React features from plain functions, no classes, no this, no lifecycle gymnastics, and they have been the default way to write React since 16.8. Yet many developers still write class components or copy hook patterns without understanding the mental model, which is why their code mysteriously breaks.
This tutorial assumes you already know the basics of React components and JSX. What we are going to do is rebuild the mental model around hooks, starting from the two hooks you will use in almost every component, then moving to the ones that solve specific problems. By the end, you should not just be able to use hooks; you should be able to reason about why a hook behaves the way it does, and that is what separates fluent React developers from people who patch symptoms.
Why hooks beat class components, in one paragraph
Class components split related logic across several lifecycle methods. Data fetching happens in componentDidMount, cleanup in componentWillUnmount, and updates in componentDidUpdate, so one feature's code is scattered across your file. Hooks group related logic together: useEffect puts a fetch and its cleanup next to each other, and custom hooks let you extract a whole reusable behavior into a named function that you can drop into any component. That single organizational improvement, related code stays together and becomes reusable, is the entire argument. Everything else follows.

If you are new to the React ecosystem itself, the learn React 2026 content on SkillGoHub covers project setup and the fundamentals, and this tutorial assumes you can create a component and render JSX. Hooks assume React; they do not replace the need to understand components in the first place.
useState: state without the ceremony
useState is the hook that replaces class state entirely. You call it with an initial value and it returns an array: the current value and a setter function. So const [count, setCount] = useState(0) gives you a count variable starting at 0 and a setCount function to change it. The array destructuring is the bit people often gloss over; what matters is that you get the value and a stable way to update it.

The single most important thing to internalize about useState is that the setter is asynchronous and replaces the whole value. If you call setCount(count + 1) twice in a row, you may not get an increment of two, because both reads see the same stale count. When you need to update based on the previous value, use the functional form: setCount(prev => prev + 1). This is the classic beginner bug, and knowing why it happens saves hours of confusion.
Also remember that state is per-component-instance, not shared. Every render of a component gets its own state. If two component instances need to share state, you lift it up to a common parent. Understanding where state lives, and when to lift it, is more important than knowing the syntax, because most state bugs are actually state-location bugs.
useEffect: handling side effects and the dependency array
useEffect lets you run side effects, things outside rendering like data fetching, subscriptions, or updating the document title. The signature is useEffect(callback, dependencies). The callback runs after render, and it re-runs when any value in the dependency array changes. An empty dependency array, useEffect(callback, []), runs the effect only on mount, which is how you do the equivalent of componentDidMount.

The dependency array is where most of React's subtle bugs live. Misspell it, omit a value the effect reads, and you either run the effect too often or capture a stale value. The rule that clears up most confusion is dead simple: include every variable your effect reads in the dependency array. If you find yourself tempted to suppress lint warnings about dependencies, stop and think, because that warning is usually catching a real bug.
Effects can return a cleanup function, which React calls before the next effect run and on unmount. This is how you cancel a subscription or abort an in-flight request. If you fetch data and the component unmounts, the cleanup aborts the request, preventing the infamous "set state on an unmounted component" warning and its memory leaks. Grouping the effect and its cleanup in one place is precisely the organizational win that hooks were designed to deliver.
Rules of hooks and the render cycle
Two rules govern all hooks, and violating them produces errors that are hard to read. First, call hooks only at the top level of your component function, never inside loops, conditions, or nested functions. React relies on the order of hook calls being identical across every render so it can pair each call with the right state. Second, call hooks only from React function components or custom hooks, not from plain JavaScript functions.

Why do the rules exist? Because React tracks state positionally. Each time your component renders, React expects hooks to be called in the same order, and it maps each hook call to the state from the previous render by position. Put a hook inside a condition, and on one render it runs and on the next it does not, shifting every subsequent hook's position and corrupting all the state after it. This is why the lint rule is non-negotiable rather than a style preference.
Rendering in React is a pure function of props and state. Each render produces a snapshot, and effects run after. Understanding that renders are snapshots, not live views, explains why a value captured in a closure behaves the way it does. If an effect reads a value but does not include it in dependencies, it captures the value from the render that created it, not the latest one. This mental model of snapshots plus dependency arrays resolves most hook mysteries.
useContext, useReducer, and escape hatches
As your component tree grows, prop drilling, passing props down five levels, becomes painful. useContext lets a component read a context value provided higher up the tree without manual prop passing. You create context with createContext, wrap the tree in a provider with a value, and any descendant reads it with useContext. It is ideal for app-wide things like theme, auth user, or locale.

useReducer is useState's more structured sibling. It models state as (state, action) => newState and is a good fit when state transitions are complex or involve multiple related pieces, like a form with many fields or a state machine with distinct intents. It keeps the update logic in one centralized reducer function, making behavior easier to test and reason about than a pile of separate setState calls. As your projects grow, version-controlling these state-machine components becomes part of normal workflow, and a Git and GitHub tutorial helps you keep that work tracked and reviewable.
When you need to reach outside React, useRef gives you a mutable value that persists across renders without triggering re-renders, typically used to hold a reference to a DOM node or an imperative value like an interval ID. useMemo and useCallback optimize performance by caching values and functions, but reach for them only when profiled performance actually requires it, because premature memoization adds complexity for little gain. These are the tools that fill out the hook toolbox.
Build a custom hook and see the real payoff
| Platform / Tool | Key Features | Pricing |
|---|---|---|
| React DevTools | Inspect component tree, hooks state, profile renders | Free browser extension |
| React Router | Client-side routing for React apps | Free and open source |
| TanStack Query | Data fetching and caching hooks, auto-refetch | Free (MIT licensed) |
| Zustand | Small global state store with hooks | Free (MIT licensed) |
| Storybook | Component development environment and testing | Free (Storybook); paid hosting add-ons |
| Next.js | React framework with routing, SSR, and data fetching | Free (MIT); optional paid deploy features |
The tool that unlocks hooks' full power is the custom hook, which is just a function that calls other hooks and returns whatever you want. You write a function starting with use, like function useLocalStorage(key, initialValue), and inside it you wire up a state value that syncs to localStorage and returns both the value and a setter. That one custom hook then handles a feature many apps need with a single line at the call site.
Custom hooks let you extract data-fetching logic, form state, online-status detection, debounced search input, almost anything repetitive, into a named, testable unit. This is the organizational win taken to its logical end: instead of copying the same 20 lines of effect into fifteen components, you have one hook and fifteen one-line calls. The tooling that makes hooks productive is worth knowing, and the React DevTools extension is the best way to inspect what your hooks are actually doing when behavior surprises you. Pair this with the React Native development guide when you want to reuse the same custom hooks on mobile, and the React fundamentals summary when you need a quick syntax refresher before wiring up a new hook.
Common hook bugs and how to actually debug them
The most common hook bug is stale closure data. Your effect captures a value, the value later changes, but the effect does not re-run because it is missing from the dependency array. The fix, again, is dependency discipline: include every read value, or restructure so the effect does not need the stale value. When in doubt, log the dependencies and the captured value to see the mismatch.
Infinite loops are the second classic failure, usually caused by an effect that updates state which the effect also depends on, recreating a value like an inline function or object that changes identity every render. Fix the loop by removing the state update from the dependency set or by stabilizing the dependency with useCallback/useMemo only when it is genuinely worth it. The pattern to recognize: effect depends on X, effect sets X, X changes identity, effect runs again.
Finally, remember that hooks are per-instance and snapshots are immutable. A strong JavaScript foundation makes a lot of these issues obvious, so if closures, callbacks, and array methods feel shaky, revisiting the JavaScript for beginners material first will remove half the confusion you encounter while debugging hooks. For a solid walkthrough of the React side, the learn React fundamentals article pairs well here, and once you have hooks down, extending to React Native development on SkillGoHub reuses the same mental model for mobile screens. The hooks you learn for the web transfer directly, because React Native uses the identical hooks API. Mastering the rendering and state mental model is the skill, and hooks are how you exercise it everywhere React runs.
For more, check out: .
For more, check out: .
FAQ
Why does my state not update immediately after calling setState?
Because the setter is asynchronous and schedules a re-render rather than mutating the value instantly. If you read the variable right after calling the setter, you still see the old value from the current render's snapshot. If you need the updated value to compute the next one, use the functional form, setState(prev => ...), which receives the latest value and avoids relying on the stale captured one.
Should I always add every variable to the useEffect dependency array?
Yes, as the baseline rule, include every value the effect reads. This prevents stale closures and matches the behavior the lint rules enforce. There are then two reasons you might refine it: you may want to run an effect intentionally on a subset, or you might restructure so an effect does not need a value at all. The dependency array is not a place to ignore correctness for convenience; it is a contract between the effect and the values it uses.
My effect runs in an infinite loop. What is causing it?
Almost always, the effect updates state that appears in its dependency array, and every update recreates the dependency, triggering the effect again. The most common culprit is an inline function or object that has a new identity each render. Break the cycle by removing the state change from the dependency array, or by stabilizing the dependency with useCallback or useMemo, but only after confirming the loop and its cause rather than silently suppressing it.
When should I use useReducer instead of useState?
Prefer useReducer when state transitions are complex, involve multiple related values, or follow distinct intents, like a multi-field form, a wizard, or a component with many actions. It centralizes update logic in one reducer function, which is easier to test and reason about. For simple independent values, useState stays cleaner. A handy heuristic: if you keep writing several setState calls that always change together, or if update logic grows complex with branching, move to a reducer.
Do I need useMemo and useCallback everywhere for performance?
No. useMemo and useCallback exist to avoid unnecessary child re-renders and to keep reference identity stable, but they add their own overhead and complexity. Add them only after you have profiled with React DevTools and identified a real performance problem caused by re-renders or reference churn. Premature memoization makes code harder to read with little benefit; measure first, optimize second.