How to Use React Hooks

Introduction React Hooks revolutionized how developers manage state and side effects in functional components. Introduced in React 16.8, Hooks like useState, useEffect, and useContext eliminated the need for class components and brought a more intuitive, composable approach to building UIs. But with power comes complexity. Many developers adopt Hooks without fully understanding their behavior, lea

Oct 25, 2025 - 13:19
Oct 25, 2025 - 13:19
 0

Introduction

React Hooks revolutionized how developers manage state and side effects in functional components. Introduced in React 16.8, Hooks like useState, useEffect, and useContext eliminated the need for class components and brought a more intuitive, composable approach to building UIs. But with power comes complexity. Many developers adopt Hooks without fully understanding their behavior, leading to bugs, performance issues, and unmaintainable code.

This article focuses on the top 10 ways to use React Hooks you can trustpatterns that have been battle-tested in production, endorsed by the React team, and validated by the community. These are not just syntax tips; they are principles grounded in Reacts core philosophy of predictability, reactivity, and consistency.

Whether you're new to Hooks or looking to refine your existing codebase, this guide will help you avoid common anti-patterns and adopt practices that ensure your applications remain performant, testable, and scalable. Trust in your code begins with understanding how Hooks truly workand how to use them correctly.

Why Trust Matters

React Hooks are not magic. They follow strict rulesRules of Hooksthat govern when and how they can be called. Violating these rules doesnt always produce immediate errors; sometimes, the bugs manifest as subtle, intermittent issues that are hard to debug. A component might re-render unexpectedly, state might become stale, or effects might run too oftenor not at all.

Trust in your codebase is built on predictability. When you use Hooks correctly, you know exactly when state updates will occur, when side effects will fire, and how data flows through your components. When you use them incorrectly, you introduce uncertainty. That uncertainty compounds over time, especially in large teams or long-lived applications.

Consider this: a poorly implemented useEffect can trigger API calls on every keystroke, overwhelming your backend. A misplaced useState can cause a component to lose its state during re-renders. A custom Hook that doesnt properly memoize values can cause unnecessary recalculations, degrading performance.

Trusting your Hooks means understanding their contracts. It means knowing that useState is batched, that useEffect runs after paint, that useCallback and useMemo are optimizationsnot solutions to logic errors. It means writing code that behaves the same way every time, regardless of render frequency, component hierarchy, or environment.

This section isnt about fearits about responsibility. React gives you freedom. But with freedom comes the obligation to use it wisely. The top 10 patterns below are your guide to using that freedom responsibly.

Top 10 How to Use React Hooks

1. Always Use useState for Local State Management

useState is the most fundamental Hook and should be your default choice for managing local component state. Unlike props, state is mutable and persists across renders. Use it for UI state like form inputs, toggle booleans, modals, and loading indicators.

Do not overcomplicate it. Avoid using useState for derived data. For example, if you have an array of items and need to count them, dont store the count in state. Instead, compute it with a variable or use useMemo:

const items = ['apple', 'banana', 'orange'];

const itemCount = items.length; // ? Computed, not stored

However, if the count depends on external state that changes, useMemo ensures its only recalculated when dependencies change:

const itemCount = useMemo(() => items.length, [items]);

Always initialize useState with a sensible default. Avoid null or undefined unless necessary. For objects and arrays, use literal syntax:

const [user, setUser] = useState({ name: '', email: '' });

const [todos, setTodos] = useState([]);

Never mutate state directly. Always use the setter function:

// ? Wrong

user.name = 'John';

// ? Correct

setUser({ ...user, name: 'John' });

For complex state updates, use functional updates to ensure youre working with the latest state:

setCount(prevCount => prevCount + 1);

This is critical in asynchronous contexts or when multiple state updates occur in quick succession. React batches state updates, but functional updates guarantee youre not working with stale closures.

2. Use useEffect for Side Effects Only

useEffect is designed for side effects: data fetching, subscriptions, DOM manipulations, timers, and logging. It should never be used for pure computation or state updates that dont interact with the outside world.

Always provide a dependency array. Omitting it causes the effect to run after every render, which is rarely intended and often a performance bottleneck:

// ? Dangerousruns on every render

useEffect(() => {

document.title = You clicked ${count} times;

});

// ? Correctruns only when count changes

useEffect(() => {

document.title = You clicked ${count} times;

}, [count]);

If you need the effect to run only once on mount, pass an empty array:

useEffect(() => {

fetch('/api/data')

.then(res => res.json())

.then(data => setData(data));

}, []);

Always clean up side effects that require disposal: subscriptions, event listeners, timers. Use the cleanup function returned by useEffect:

useEffect(() => {

const timer = setInterval(() => {

console.log('Tick');

}, 1000);

return () => clearInterval(timer); // ? Cleanup

}, []);

Never put state setters inside useEffect without proper dependencies. This creates infinite loops:

// ? Infinite loop

useEffect(() => {

setCount(count + 1);

}, [count]);

Instead, use functional updates if you need to update state based on previous state:

useEffect(() => {

setCount(prev => prev + 1);

}, [dependency]); // Only trigger on specific changes

Remember: useEffect runs after the browser paints. Its not for synchronous logic. If you need immediate state updates based on props, consider using useState with derived state or useReducer.

3. Memoize with useCallback and useMemo to Prevent Unnecessary Re-renders

React re-renders components when props or state change. But sometimes, those changes trigger unnecessary re-renders in child componentseven if the values are logically identical.

useCallback returns a memoized version of a function. Use it when passing functions as props to optimized child components (e.g., those using React.memo):

const handleClick = useCallback(() => {

setCount(prev => prev + 1);

}, [setCount]);

Without useCallback, a new function is created on every render, causing the child component to re-render even if its props havent meaningfully changed.

useMemo memoizes the result of a computation. Use it for expensive calculations:

const expensiveValue = useMemo(() => {

return computeExpensiveValue(a, b);

}, [a, b]);

Do not overuse useMemo or useCallback. They add overhead. Only use them when profiling confirms a performance issue. A common mistake is memoizing everything:

// ? Unnecessary

const name = useMemo(() => props.name, [props.name]);

const age = useMemo(() => props.age, [props.age]);

These are cheap primitives. Reacts reconciliation is efficient enough. Focus memoization on complex objects, large arrays, or heavy functions.

Also, avoid using useMemo to fix broken logic. If your component behaves incorrectly without useMemo, the issue is likely a state or effect mismanagementnot a performance problem.

4. Avoid Inline Functions and Objects in Dependencies

One of the most common causes of infinite loops and unnecessary re-renders is passing inline functions or objects as dependencies to useEffect, useCallback, or useMemo.

// ? Bad

useEffect(() => {

fetch('/api/user', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({ id: userId })

});

}, [{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: userId }) }]);

This creates a new object on every render, so the dependency array always changes, triggering the effect repeatedly.

Solution: Move inline objects and functions outside the effect, or memoize them:

const requestConfig = useMemo(() => ({

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({ id: userId })

}), [userId]);

useEffect(() => {

fetch('/api/user', requestConfig);

}, [requestConfig]);

Alternatively, extract the function:

const handleFetch = useCallback(() => {

fetch('/api/user', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({ id: userId })

});

}, [userId]);

useEffect(() => {

handleFetch();

}, [handleFetch]);

When in doubt, ask: Does this value change between renders? If its created inline, it does. Always prefer stable references.

5. Create Custom Hooks for Reusable Logic

Custom Hooks are one of Reacts most powerful features. They allow you to extract component logic into reusable functions that can use other Hooks internally.

Always name custom Hooks with the prefix use to follow React conventions and enable tooling support (e.g., ESLint rules):

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 {

setStoredValue(value);

window.localStorage.setItem(key, JSON.stringify(value));

} catch (error) {

console.error(error);

}

};

return [storedValue, setValue];

}

Now you can use it anywhere:

const [user, setUser] = useLocalStorage('user', { name: '' });

const [theme, setTheme] = useLocalStorage('theme', 'light');

Custom Hooks should not render JSX. They should only manage state and side effects. This keeps them pure and testable.

Always handle edge cases: null, undefined, browser APIs (like localStorage or window), and error states. Wrap external dependencies in try-catch blocks and provide fallbacks.

Test custom Hooks using React Testing Librarys renderHook utility. Never test them as if they were regular functionsthey rely on Reacts rendering lifecycle.

6. Use useReducer for Complex State Logic

useState works great for simple state. But when state logic becomes complexmultiple sub-values, dependencies between state fields, or state transitions based on actionsuseReducer is the better choice.

useReducer follows the Redux pattern: state is updated by dispatching actions to a pure reducer function:

const initialState = { count: 0, step: 1 };

function reducer(state, action) {

switch (action.type) {

case 'increment':

return { ...state, count: state.count + state.step };

case 'decrement':

return { ...state, count: state.count - state.step };

case 'setStep':

return { ...state, step: action.step };

default:

throw new Error();

}

}

function Counter() {

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

return (

Count: {state.count}

>

);

}

useReducer is especially useful when:

  • State transitions are complex or involve multiple variables
  • You need to pass state updates deep into a component tree
  • You want to make state logic testable and reusable

It also integrates well with useContext for global state management without external libraries.

Always define your reducer as a pure function. Never mutate state directly. Always return a new object. This ensures predictable behavior and enables time-travel debugging tools.

7. Use useContext for Global State Without Prop Drilling

Context is ideal for sharing data that needs to be accessed by many components at different levels of the treetheme, user authentication, language settings, etc.

First, create a context:

const ThemeContext = createContext({

theme: 'light',

toggleTheme: () => {}

});

Then, wrap your app (or section) with a provider:

function App() {

const [theme, setTheme] = useState('light');

const toggleTheme = () => {

setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');

};

const value = { theme, toggleTheme };

return (

);

}

Now any descendant can consume it:

function Header() {

const { theme, toggleTheme } = useContext(ThemeContext);

return (

);

}

Important: Avoid creating the context value inline on every render. This causes unnecessary re-renders in all consumers:

// ? Badnew object on every render

// ? Gooduse useMemo

({ theme, toggleTheme }), [theme, toggleTheme])}>

Also, avoid using Context for everything. Its not a replacement for state management libraries like Zustand or Redux when you need middleware, devtools, or complex actions. Use Context for simple, stable, global values.

8. Handle Asynchronous State with Proper Loading and Error States

Data fetching with useEffect is commonbut often poorly handled. Many components show a blank screen or crash when data is loading or an error occurs.

Always manage asynchronous state explicitly:

function UserProfile({ userId }) {

const [user, setUser] = useState(null);

const [loading, setLoading] = useState(true);

const [error, setError] = useState(null);

useEffect(() => {

const fetchUser = async () => {

setLoading(true);

setError(null);

try {

const response = await fetch(/api/users/${userId});

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

const data = await response.json();

setUser(data);

} catch (err) {

setError(err.message);

} finally {

setLoading(false);

}

};

fetchUser();

}, [userId]); if (loading) return

Loading...; if (error) return

Error: {error}; if (!user) return

No user found;

return

{user.name}
;

}

Use a finally block to ensure loading state is always reset, even if an error occurs.

Never set state after a component unmounts. This causes memory leaks and React warnings:

useEffect(() => {

let isMounted = true;

fetch('/api/data')

.then(res => res.json())

.then(data => {

if (isMounted) {

setData(data);

}

});

return () => {

isMounted = false;

};

}, []);

Alternatively, use AbortController for cancellable fetches:

useEffect(() => {

const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })

.then(res => res.json())

.then(data => setData(data))

.catch(err => {

if (err.name !== 'AbortError') {

setError(err.message);

}

});

return () => controller.abort();

}, []);

Always provide fallback UIs. Users should never see a broken component due to missing data.

9. Avoid Using Hooks Inside Conditions or Loops

Reacts Rules of Hooks state: Only call Hooks at the top level. Dont call Hooks inside loops, conditions, or nested functions.

This rule exists because React relies on the order of Hook calls to maintain state between renders. If you conditionally call a Hook, the order changes, and React loses track of which state corresponds to which Hook.

// ? Violates Rules of Hooks

function MyComponent({ shouldShow }) {

if (shouldShow) {

const [count, setCount] = useState(0); // ? Conditional Hook

}

return

{count}
;

}

This may seem harmless, but if shouldShow toggles, React cant match the useState call to its previous instance. The result is unpredictable state behavior or runtime errors.

Instead, move the Hook outside the condition and control rendering with logic:

function MyComponent({ shouldShow }) {

const [count, setCount] = useState(0); // ? Always called

if (!shouldShow) {

return null;

}

return

{count}
;

}

Same applies to loops:

// ? Bad

function TodoList({ todos }) {

todos.forEach(todo => {

const [isDone, setIsDone] = useState(todo.done); // ? Hook inside loop

});

}

? Correct: Render a component for each todo, and let each component manage its own state:

function TodoList({ todos }) {

return todos.map(todo => (

));

}

function TodoItem({ todo }) {

const [isDone, setIsDone] = useState(todo.done); // ? Hook at top level return

  • setIsDone(!isDone)}>{todo.text}
  • ;

    }

    Linting tools like ESLint with the react-hooks/rules-of-hooks plugin will catch these violations. Always run them in your CI pipeline.

    10. Test Custom Hooks with React Testing Library

    Custom Hooks are logicthey deserve tests. But you cant test them like regular functions because they rely on Reacts rendering engine.

    Use React Testing Librarys renderHook utility:

    import { renderHook, act } from '@testing-library/react-hooks';
    

    test('useLocalStorage saves and retrieves value', () => {

    const { result } = renderHook(() => useLocalStorage('test', 'default'));

    act(() => {

    result.current[1]('new value');

    });

    expect(result.current[0]).toBe('new value');

    expect(localStorage.getItem('test')).toBe('new value');

    });

    Always wrap state updates in act() to ensure React has finished processing updates before assertions.

    Test edge cases: empty strings, null values, browser API failures, and invalid JSON in localStorage.

    Also test cleanup behaviorfor example, if your Hook adds an event listener, ensure its removed on unmount:

    test('useWindowResize cleans up event listener', () => {
    

    const { unmount } = renderHook(() => useWindowResize());

    const mockListener = jest.fn();

    window.addEventListener = jest.fn((event, listener) => {

    if (event === 'resize') mockListener();

    });

    unmount();

    expect(window.removeEventListener).toHaveBeenCalled();

    });

    Testing ensures your Hooks behave consistently across environments and dont break with future React updates.

    Comparison Table

    Hook Primary Use Case Common Mistake Best Practice
    useState Local component state Mutating state directly Use functional updates and initialize with defaults
    useEffect Side effects (fetching, subscriptions) Omitting dependency array Always provide dependencies; clean up side effects
    useCallback Memoize functions passed as props Memoizing simple functions unnecessarily Use only when child components use React.memo
    useMemo Memoize expensive calculations Using it to fix logic errors Use only after profiling confirms performance gain
    useReducer Complex state logic Using useState for deeply nested state Use for state transitions with multiple variables
    useContext Avoid prop drilling Creating new context value on every render Wrap value in useMemo for stability
    Custom Hooks Reusability Returning JSX or calling Hooks conditionally Name with use prefix; return only state and functions
    useLayoutEffect Synchronous DOM mutations Using it for data fetching Use only when you need to read layout before paint
    useRef Access DOM nodes or store mutable values Using ref to trigger re-renders Ref values dont trigger re-rendersuse for persistence
    useDebugValue Debug custom Hooks in DevTools Overloading with too much info Use sparingly to show meaningful state

    FAQs

    Can I use multiple useState hooks in one component?

    Yes. Each useState call manages its own independent state. Its common and encouraged to use multiple useState hooks for different pieces of state. For example:

    const [name, setName] = useState('');
    

    const [email, setEmail] = useState('');

    const [isLoading, setIsLoading] = useState(false);

    This is clearer than managing a single object with multiple fields unless those fields are tightly coupled.

    When should I use useLayoutEffect instead of useEffect?

    Use useLayoutEffect when you need to perform DOM mutations that affect layout (e.g., measuring elements, scrolling to a position) and want to avoid visual flicker. It runs synchronously after all DOM mutations but before the browser paints. However, it can block rendering and hurt performanceuse it only when necessary. For 99% of cases, useEffect is sufficient.

    Do Hooks replace Redux?

    Not entirely. For simple apps, useContext and useReducer can replace Redux. But for large applications with complex state, middleware, devtools, or time-travel debugging, Redux (or alternatives like Zustand or Jotai) still offer advantages. Hooks provide primitives; libraries build on top of them to solve advanced problems.

    Why does my component re-render even when state hasnt changed?

    This often happens when props or context values change. Even if the state value is the same, if the reference changes (e.g., a new object or function is created on every render), React will re-render. Use useMemo and useCallback to stabilize references. Also, check if parent components are re-rendering unnecessarily.

    Can I call a Hook from a regular JavaScript function?

    No. Hooks can only be called from React function components or other custom Hooks. This is enforced by Reacts rules to preserve the order and consistency of state between renders. Calling a Hook from a utility function will cause an error.

    How do I know if I need to optimize with useMemo or useCallback?

    Use React DevTools Highlight Updates feature to see which components re-render. If a child component re-renders frequently due to a new function or object prop, then memoize it. Never optimize prematurely. Profile first, optimize second.

    What happens if I forget a dependency in useEffect?

    React will not warn you in production, but in development, the React DevTools extension and ESLint plugin will alert you. The effect may run too often (if you include too many dependencies) or too rarely (if you omit necessary ones). Missing dependencies often lead to stale closureswhere the effect uses outdated values from previous renders.

    Is it safe to use Hooks with class components?

    No. Hooks only work in functional components. You cannot use useState or useEffect inside a class. However, you can wrap a class component in a functional component that uses Hooks and pass props down. This is useful for gradual migration.

    Do Hooks make components harder to test?

    No. In fact, they make them easier. Since Hooks extract logic into reusable functions, you can test that logic independently using renderHook. This promotes better separation of concerns and clearer test cases than testing class-based lifecycle methods.

    Can I use Hooks in server-side rendering (SSR)?

    Yes. React Hooks work perfectly with SSR frameworks like Next.js or Remix. Just ensure that side effects (like localStorage or window access) are wrapped in checks for the client environment:

    const [isClient, setIsClient] = useState(false);
    

    useEffect(() => {

    setIsClient(true);

    }, []);

    if (!isClient) {

    return

    Loading...
    ;

    }

    This prevents hydration mismatches between server and client.

    Conclusion

    React Hooks are not just syntactic sugarthey are a fundamental shift in how we think about state and side effects in UI development. But their power lies not in their existence, but in how wisely we use them.

    The top 10 patterns outlined in this guide are not arbitrary rules. They are distilled from years of real-world experience, community feedback, and Reacts own internal design principles. They represent the difference between code that works and code that you can trustcode that scales, that teams can maintain, and that users wont notice breaking under load.

    Trust in your code comes from consistency. It comes from understanding that useState is for state, useEffect is for side effects, and useMemo is for performancenot logic. It comes from testing your custom Hooks, avoiding inline objects in dependencies, and never calling Hooks conditionally.

    As React continues to evolve, these patterns will remain foundational. They are not tied to a version or a librarythey are principles that transcend frameworks. Master them, and youll not only write better React codeyoull write code that lasts.

    Start today. Audit your components. Replace anti-patterns. Write custom Hooks with intention. Test your logic. And above alltrust the process, not the shortcut.