Mastering React Hooks: useEffect, useReducer, and Custom Hooks

by Didin J. on Sep 22, 2025 Mastering React Hooks: useEffect, useReducer, and Custom Hooks

Master React Hooks: Learn useEffect, useReducer, and custom hooks with TypeScript. Build reusable logic and scalable, maintainable React apps step by step.

React has come a long way since the days of class components. With the introduction of Hooks in React 16.8, developers gained a powerful way to manage state, handle side effects, and share logic across components—all without writing a single class. Today, hooks are the standard way to build modern React applications.

In this tutorial, we’ll dive deep into three essential hooks that every React developer should master:

  • useEffect – for managing side effects such as data fetching, DOM updates, and event listeners.

  • useReducer – for handling more complex state transitions and building predictable state management patterns.

  • Custom Hooks – for extracting reusable logic, keeping components clean, and improving maintainability.

By the end of this guide, you’ll not only understand how these hooks work, but also when and why to use them. We’ll explore practical examples and build a small project that ties everything together.

Whether you’re just starting with React or looking to strengthen your hook knowledge, this tutorial will help you write cleaner, more scalable, and more reusable code.


Prerequisites

Before diving into React Hooks, make sure you have the following in place:

Knowledge Requirements

  • Basic understanding of JavaScript ES6+ features such as arrow functions, destructuring, and modules.

  • Familiarity with React fundamentals like components, props, and state.

  • Some experience with the command line and running npm/yarn scripts.

🛠 Tools and Environment

  • Node.js (version 18 or later) is installed on your system.

  • npm (comes with Node.js) or yarn/pnpm as a package manager.

  • A modern code editor like Visual Studio Code.


Setting Up the Project

Now that the project is running, let’s set it up properly for our hooks exploration. Since we’re using Vite with React + TypeScript, we’ll also configure the structure for clarity and maintainability.

🔹 1. Create the Project with TypeScript

When creating the project, choose the React + TypeScript template:

npm create vite@latest react-hooks-tutorial -- --template react-ts
cd react-hooks-tutorial
npm install
npm run dev

This initializes a TypeScript-ready React project.

Open http://localhost:5173 in your browser, and you should see the default Vite + React page running.

Mastering React Hooks: useEffect, useReducer, and Custom Hooks - vite + react

🔹 2. Clean Up Boilerplate

Vite ships with some demo files we don’t need. Let’s remove them and start fresh.

  1. Delete the following files inside src/:

    • App.css

    • index.css

    • assets/ folder (optional, unless you want to keep the Vite logo)

  2. Update src/main.tsx to look like this:

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
  1. Update src/App.tsx to a minimal version:
import React from 'react'

function App() {
  return (
    <div style={{ padding: '2rem' }}>
      <h1>Mastering React Hooks</h1>
      <p>useEffect, useReducer, and Custom Hooks Tutorial</p>
    </div>
  )
}

export default App

🔹 3. Suggested Folder Structure

To keep things organized as we add examples, let’s create a simple folder structure:

src/
│── hooks/        # custom hooks will go here
│── components/   # reusable UI components
│── examples/     # step-by-step hook examples
│── App.tsx
│── main.tsx

You can create these folders now. We’ll start filling them in as we go through useEffect, useReducer, and custom hooks.

At this point, your project should compile and display the simple welcome text. We’re now ready to explore our first hook.


Understanding useEffect (step-by-step)

useEffect is the hook that lets you perform side effects in function components: data fetching, subscriptions, DOM updates, timers, and more. Think of it as the combination of componentDidMount, componentDidUpdate, and componentWillUnmount from class components — but much more flexible.

Below I break useEffect into three step-by-step examples (with TypeScript) so you can copy/paste them into src/examples/.

1. Basic useEffect: mount, update, and cleanup

What you’ll learn: the effect runs on mount, runs again when dependencies change, and can return a cleanup that runs before the next effect or on unmount.

// src/examples/BasicEffect.tsx
import React, { useEffect, useState } from "react";

export default function BasicEffect(): React.ReactElement {
  const [count, setCount] = useState<number>(0);
  const [show, setShow] = useState<boolean>(true);

  // runs on mount and whenever `count` changes
  useEffect(() => {
    console.log("Effect: mounted or count changed ->", count);

    // optional cleanup (runs before next effect / on unmount)
    return () => {
      console.log("Cleanup for count effect. previous count ->", count);
    };
  }, [count]);

  // runs only once (mount)
  useEffect(() => {
    console.log("Mount-only effect");
  }, []);

  // no dependency array -> runs after every render (use with caution)
  useEffect(() => {
    console.log("Runs after every render");
  });

  return (
    <div
      style={{
        border: "1px solid #ddd",
        padding: "1rem",
        marginBottom: "1rem"
      }}
    >
      <h3>Basic useEffect</h3>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>{" "}
      <button onClick={() => setShow((s) => !s)}>
        {show ? "Hide" : "Show"}
      </button>
      {show && (
        <p>Toggle to see mount/unmount behavior of nested components.</p>
      )}
    </div>
  );
}

Step-by-step notes

  • [] (empty deps) → effect runs once after mount.

  • Omit deps → effect runs after every render (be careful — this can cause infinite loops).

  • [count] → effect runs after mount and whenever count changes.

  • The returned function is the cleanup; it runs before the next effect run and on unmount.

2. Data fetching with cancellation (avoid memory leaks)

What you’ll learn: fetch data inside useEffect, show loading/error, and cancel on unmount using AbortController.

// src/examples/FetchUsersExample.tsx
import React, { useEffect, useState } from "react";

interface User {
  id: number;
  name: string;
  email: string;
}

export default function FetchUsersExample(): React.ReactElement {
  const [users, setUsers] = useState<User[] | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    async function load() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users", {
          signal
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data: User[] = await res.json();
        setUsers(data);
      } catch (err: any) {
        if (err.name === "AbortError") {
          // fetch aborted — expected on unmount
          console.log("Fetch aborted");
        } else {
          setError(err.message || "Unknown error");
        }
      } finally {
        setLoading(false);
      }
    }

    load();

    // cleanup: abort fetch if component unmounts
    return () => controller.abort();
  }, []); // empty deps -> run once on mount

  return (
    <div
      style={{
        border: "1px solid #ddd",
        padding: "1rem",
        marginBottom: "1rem"
      }}
    >
      <h3>Fetching data (useEffect + AbortController)</h3>
      {loading && <p>Loading users…</p>}
      {error && <p style={{ color: "red" }}>Error: {error}</p>}
      {users && (
        <ul>
          {users.map((u) => (
            <li key={u.id}>
              {u.name} — {u.email}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Step-by-step notes

  • Use AbortController to cancel fetch on unmount — prevents setState on an unmounted component and avoids console errors.

  • Keep loading and error state to improve UX.

  • If fetching depends on props or user input (e.g., a query), add that value to the dependency array so the effect re-runs when it changes.

3. Cleanup: event listeners and timers

What you’ll learn: add/remove event listeners safely and clear intervals to avoid memory leaks and duplicate handlers.

// src/examples/CleanupExample.tsx
import React, { useEffect, useState } from "react";

export default function CleanupExample(): React.ReactElement {
  const [mouseX, setMouseX] = useState<number>(0);
  const [seconds, setSeconds] = useState<number>(0);

  // event listener (mount -> add, cleanup -> remove)
  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => setMouseX(e.clientX);
    window.addEventListener("mousemove", handleMouseMove);
    return () => window.removeEventListener("mousemove", handleMouseMove);
  }, []); // empty deps -> add once, remove on unmount

  // interval example (use functional update to avoid stale closure)
  useEffect(() => {
    const id = window.setInterval(() => {
      setSeconds((s) => s + 1); // functional update avoids stale state
    }, 1000);
    return () => window.clearInterval(id);
  }, []);

  return (
    <div style={{ border: "1px solid #ddd", padding: "1rem" }}>
      <h3>Cleanup: event listeners & timers</h3>
      <p>Mouse X: {mouseX}</p>
      <p>Seconds since mount: {seconds}</p>
    </div>
  );
}

Step-by-step notes

  • Always remove event listeners in the cleanup to avoid duplicated listeners when the component re-renders or is unmounted.

  • For timers use window.setInterval / window.clearInterval (avoids typing issues) and clear them in the cleanup.

  • Use the functional setState(prev => next) form inside timers to avoid stale closures.

Common pitfalls & quick tips

  • Forgetting the dependency array: Omitting the array means the effect runs after every render — often not desired.

  • Wrong deps → stale values or infinite loops: Ensure every external variable used in the effect is listed in deps or deliberately omit with a comment and reason. ESLint rule react-hooks/exhaustive-deps helps.

  • Stale closures: If your effect callback captures old state/props, prefer functional updates (setState(prev => ...)) or useRef to keep the latest value.

  • Too much logic in one effect: If an effect does multiple things, split into smaller effects with appropriate dependencies.

  • Consider custom hooks: Reusable logic (like data fetching) is a great candidate to extract into a custom hook — we’ll cover that in Section 6.

How to run these examples

  1. Create the three files above in src/examples/.

  2. Import them in src/App.tsx to preview:

// src/App.tsx (snippet)
import React from "react";
import BasicEffect from "./examples/BasicEffect";
import FetchUsersExample from "./examples/FetchUsersExample";
import CleanupExample from "./examples/CleanupExample";

export default function App(): React.ReactElement {
  return (
    <div style={{ padding: "2rem" }}>
      <h1>Mastering React Hooks</h1>
      <BasicEffect />
      <FetchUsersExample />
      <CleanupExample />
    </div>
  );
}
  1. Run the dev server (npm run dev) and open the page. Open your browser console to see effect logs and try interactions.

Mastering React Hooks: useEffect, useReducer, and Custom Hooks - hooks examples


Mastering useReducer

The useReducer hook is an alternative to useState when you need more predictable state transitions. Instead of updating the state directly, you dispatch actions that a reducer function interprets to produce the new state.

useReducer is especially useful when:

  • State depends on the previous state.

  • State has multiple sub-values (e.g., object with nested fields).

  • You want more predictable and testable updates (similar to Redux).

1. Syntax of useReducer

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

 

  • reducer: a function (state, action) => newState.

  • initialState: the starting value of your state.

  • state: current state value.

  • dispatch(action): function to trigger state updates.

2. Example 1: Counter with useReducer

This example mirrors the classic counter from useState, but using reducer logic.

// src/examples/CounterReducer.tsx
import React, { useReducer } from 'react';

// define action types
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };

// define state type
interface State {
  count: number;
}

// reducer function
function counterReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state;
  }
}

export default function CounterReducer(): React.ReactElement {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div style={{ border: '1px solid #ddd', padding: '1rem', marginBottom: '1rem' }}>
      <h3>Counter with useReducer</h3>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+ Increment</button>{' '}
      <button onClick={() => dispatch({ type: 'decrement' })}>- Decrement</button>{' '}
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

Step-by-step notes:

  • Reducer is a pure function: same input → same output.

  • dispatch({ type: 'increment' }) triggers the reducer.

  • Easy to extend with more actions later.

3. Example 2: Todo List with useReducer

A more realistic example where multiple actions update the state.

// src/examples/TodoReducer.tsx
import React, { useReducer, useState } from 'react';

// types
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: number }
  | { type: 'remove'; id: number };

function todoReducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'add':
      return [
        ...state,
        { id: Date.now(), text: action.text, completed: false }
      ];
    case 'toggle':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case 'remove':
      return state.filter(todo => todo.id !== action.id);
    default:
      return state;
  }
}

export default function TodoReducer(): React.ReactElement {
  const [todos, dispatch] = useReducer(todoReducer, []);
  const [input, setInput] = useState<string>('');

  const handleAdd = () => {
    if (input.trim()) {
      dispatch({ type: 'add', text: input.trim() });
      setInput('');
    }
  };

  return (
    <div style={{ border: '1px solid #ddd', padding: '1rem' }}>
      <h3>Todo List with useReducer</h3>
      <div>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Add a todo"
        />
        <button onClick={handleAdd}>Add</button>
      </div>
      <ul>
        {todos.map(todo => (
          <li
            key={todo.id}
            style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              cursor: 'pointer'
            }}
            onClick={() => dispatch({ type: 'toggle', id: todo.id })}
          >
            {todo.text}
            <button
              style={{ marginLeft: '1rem' }}
              onClick={e => {
                e.stopPropagation(); // prevent toggle
                dispatch({ type: 'remove', id: todo.id });
              }}
            >
              ❌
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Step-by-step notes:

  • The reducer handles multiple actions (add, toggle, remove).

  • New todos are added with Date.now() as unique IDs.

  • The state is always replaced (immutable update pattern).

4. When to use useReducer vs useState

  • Use useState for simple, independent values (e.g., toggles, counters).

  • Use useReducer when:

    • State has multiple sub-values or complex logic.

    • You want centralized, predictable state updates.

    • You plan to scale the app (reducers are easier to extract/test).


Creating Custom Hooks

Custom hooks let you reuse stateful logic across components without repeating code. They are just JavaScript functions that:

  • Please start with the prefix use (so React knows they’re hooks).

  • Can use other hooks (useState, useEffect, useReducer, etc.).

  • Return values (state, functions) to be consumed by components.

Let’s start with a very practical one: useFetch.

1. Example: useFetch Hook

We’ll extract our data fetching logic into a hook, so we can reuse it anywhere.

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

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

export function useFetch<T = unknown>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    const signal = controller.signal;

    async function fetchData() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, { signal });
        if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
        const json: T = await response.json();
        setData(json);
      } catch (err: any) {
        if (err.name !== 'AbortError') {
          setError(err.message || 'Unknown error');
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

    return () => controller.abort(); // cleanup
  }, [url]);

  return { data, loading, error };
}

2. Using useFetch in a Component

Now we can consume this hook in any component with just one line:

// src/examples/UsersWithUseFetch.tsx
import React from 'react';
import { useFetch } from '../hooks/useFetch';

interface User {
  id: number;
  name: string;
  email: string;
}

export default function UsersWithUseFetch(): React.ReactElement {
  const { data: users, loading, error } = useFetch<User[]>(
    'https://jsonplaceholder.typicode.com/users'
  );

  return (
    <div style={{ border: '1px solid #ddd', padding: '1rem', marginBottom: '1rem' }}>
      <h3>Users (via useFetch)</h3>
      {loading && <p>Loading...</p>}
      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      {users && (
        <ul>
          {users.map(user => (
            <li key={user.id}>
              {user.name} — {user.email}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

3. Benefits of useFetch

  • Reusable: can fetch any endpoint by changing the url.

  • Encapsulated: all loading/error handling logic is hidden inside the hook.

  • Typed: with TypeScript generics (useFetch<User[]>) you get full type safety for the data.

4. Next Steps

We’ll now create another useful custom hook: useForm for handling form input and submission logic. This will simplify our Todo or other input-heavy components.


Example: useForm Hook

We’ll create a generic hook that manages form state, handles input changes, and optionally a reset.

// src/hooks/useForm.ts
import { useState } from 'react';

type FormValues<T> = {
    [K in keyof T]: T[K];
};

export function useForm<T extends Record<string, any>>(initialValues: T) {
    const [values, setValues] = useState<FormValues<T>>(initialValues);

    function handleChange(
        e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
    ) {
        const target = e.target as HTMLInputElement;
        const { name, value, type, checked } = target;

        setValues(prev => ({
            ...prev,
            [name]: type === 'checkbox' ? checked : value,
        }));
    }

    function reset() {
        setValues(initialValues);
    }

    return { values, handleChange, reset, setValues };
}

1. Using useForm in a Component

Let’s demonstrate it with a simple signup form:

// src/examples/SignupForm.tsx
import React from 'react';
import { useForm } from '../hooks/useForm';

interface FormData {
  name: string;
  email: string;
  subscribe: boolean;
}

export default function SignupForm(): React.ReactElement {
  const { values, handleChange, reset } = useForm<FormData>({
    name: '',
    email: '',
    subscribe: false,
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    alert(JSON.stringify(values, null, 2));
    reset();
  };

  return (
    <form
      onSubmit={handleSubmit}
      style={{ border: '1px solid #ddd', padding: '1rem', marginBottom: '1rem' }}
    >
      <h3>Signup Form (via useForm)</h3>
      <div>
        <label>
          Name:{' '}
          <input
            type="text"
            name="name"
            value={values.name}
            onChange={handleChange}
          />
        </label>
      </div>
      <div>
        <label>
          Email:{' '}
          <input
            type="email"
            name="email"
            value={values.email}
            onChange={handleChange}
          />
        </label>
      </div>
      <div>
        <label>
          <input
            type="checkbox"
            name="subscribe"
            checked={values.subscribe}
            onChange={handleChange}
          />{' '}
          Subscribe to newsletter
        </label>
      </div>
      <button type="submit">Submit</button>{' '}
      <button type="button" onClick={reset}>
        Reset
      </button>
    </form>
  );
}

2. Benefits of useForm

  • Reusable: works with any form shape (thanks to TypeScript generics).

  • Centralized: all input handling logic is in one hook instead of scattered in the component.

  • Flexible: you can extend it to include validation or async submission later.

👉 With useFetch and useForm, we now have two powerful reusable hooks.


Putting It All Together: Todo App Demo

In this section, we’ll build a Todo App with the following features:

  1. Fetch initial todos from a mock API using useFetch.

  2. Manage the todos state with useReducer.

  3. Add new todos using useForm.

  4. Toggle and remove todos interactively.

1. Project Structure

src/
│── hooks/
│   ├── useFetch.ts
│   └── useForm.ts
│── examples/
│   └── TodoAppDemo.tsx
│── App.tsx
│── main.tsx

2. Todo App Code

// src/examples/TodoAppDemo.tsx
import React, { useReducer, useEffect } from "react";
import { useFetch } from "../hooks/useFetch";
import { useForm } from "../hooks/useForm";

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

type Action =
  | { type: "initialize"; todos: Todo[] }
  | { type: "add"; text: string }
  | { type: "toggle"; id: number }
  | { type: "remove"; id: number };

function todoReducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case "initialize":
      return action.todos;
    case "add":
      return [
        ...state,
        { id: Date.now(), text: action.text, completed: false }
      ];
    case "toggle":
      return state.map((todo) =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    case "remove":
      return state.filter((todo) => todo.id !== action.id);
    default:
      return state;
  }
}

export default function TodoAppDemo(): React.ReactElement {
  const {
    data: fetchedTodos,
    loading,
    error
  } = useFetch<Todo[]>("https://jsonplaceholder.typicode.com/todos?_limit=5");

  const [todos, dispatch] = useReducer(todoReducer, []);
  const { values, handleChange, reset } = useForm<{ todo: string }>({
    todo: ""
  });

  // Initialize todos when fetched
  useEffect(() => {
    if (fetchedTodos) {
      // Only take first 5 todos for demo
      const initialTodos = fetchedTodos.map((t) => ({
        id: t.id,
        text: t.text,
        completed: t.completed
      }));
      dispatch({ type: "initialize", todos: initialTodos });
    }
  }, [fetchedTodos]);

  const handleAdd = () => {
    if (values.todo.trim()) {
      dispatch({ type: "add", text: values.todo.trim() });
      reset();
    }
  };

  return (
    <div style={{ border: "1px solid #ddd", padding: "1rem" }}>
      <h3>Todo App Demo</h3>

      {loading && <p>Loading todos...</p>}
      {error && <p style={{ color: "red" }}>Error: {error}</p>}

      <div style={{ marginBottom: "1rem" }}>
        <input
          type="text"
          name="todo"
          value={values.todo}
          onChange={handleChange}
          placeholder="Add a todo"
        />
        <button onClick={handleAdd}>Add</button>
      </div>

      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            style={{
              textDecoration: todo.completed ? "line-through" : "none",
              cursor: "pointer",
              marginBottom: "0.5rem"
            }}
            onClick={() => dispatch({ type: "toggle", id: todo.id })}
          >
            {todo.text}
            <button
              style={{ marginLeft: "1rem" }}
              onClick={(e) => {
                e.stopPropagation();
                dispatch({ type: "remove", id: todo.id });
              }}
            >
              ❌
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

3. How It Works

  1. Fetching Todos:
    useFetch loads initial todos from a mock API (jsonplaceholder) and returns { data, loading, error }.

  2. Initializing State:
    Once fetchedTodos are available, useEffect dispatches initialize to populate the reducer state.

  3. Managing Todos:

    • useReducer handles all todo actions: add, toggle, remove.

    • Each action updates state predictably.

  4. Adding Todos with Form:

    • useForm manages the input value (values.todo) and reset logic.

    • Clicking "Add" dispatches the add action and resets the input.

  5. Interactive Updates:

    • Clicking a todo toggles completed.

    • Clicking ❌ removes a todo.

4. Benefits of This Approach

  • Separation of Concerns: Fetching, form handling, and state management are all encapsulated in their respective hooks.

  • Reusability: useFetch and useForm can be reused elsewhere.

  • Predictable State: useReducer ensures predictable updates, easy to test.

  • Type Safety: TypeScript ensures all state, actions, and API data are correctly typed.

5. Try It Out

  1. Create TodoAppDemo.tsx in src/examples/.

  2. Import it in App.tsx to see it in action:

import React from 'react';
import TodoAppDemo from './examples/TodoAppDemo';

export default function App(): React.ReactElement {
  return (
    <div style={{ padding: '2rem' }}>
      <h1>Mastering React Hooks</h1>
      <TodoAppDemo />
    </div>
  );
}
  1. Run the dev server (npm run dev) and interact with the todos.

This demo combines all core concepts we’ve covered:

  • useEffect → for initializing fetched data.

  • useReducer → for state management.

  • Custom hooks → for fetching data and managing forms.


Best Practices with Hooks

React Hooks are powerful, but misuse can lead to bugs, performance issues, and hard-to-maintain code. This section covers guidelines and strategies to write clean, scalable hook-based components.

1. Follow the Rules of Hooks

React enforces two rules for hooks:

  1. Call Hooks only at the top level

    • Don’t call hooks inside loops, conditions, or nested functions.

    • Always call hooks at the top of your React function component or custom hook.

// ❌ Bad
if (someCondition) {
  const [count, setCount] = useState(0); // ERROR
}

// ✅ Good
const [count, setCount] = useState(0);
if (someCondition) { ... }
  1. Call Hooks only from React functions
  • Only call hooks from React components or other custom hooks.

function myHook() {
  const [state, setState] = useState(0); // ✅ Allowed
}

function regularFunction() {
  // const [state, setState] = useState(0); ❌ Not allowed
}

2. Manage Dependencies Carefully

  • Always include all external variables your effect depends on in the dependency array.

  • ESLint’s react-hooks/exhaustive-deps rule helps catch missing dependencies.

  • Use functional updates when referencing previous state inside effects to avoid stale closures.

useEffect(() => {
  const id = setInterval(() => setCount(prev => prev + 1), 1000);
  return () => clearInterval(id);
}, []); // ✅ safe, using functional update

3. Prefer Multiple Small Effects

Instead of a single large useEffect handling multiple responsibilities, split into smaller effects with clear dependencies:

useEffect(() => {
  // fetch user data
}, [userId]);

useEffect(() => {
  // setup subscription
}, []);
  • Easier to read, maintain, and test.

  • Avoids unintended re-renders and infinite loops.

4. Extract Reusable Logic into Custom Hooks

  • If multiple components share logic (data fetching, form handling, timers), extract it into a custom hook.

  • This keeps components clean and encourages reusability.

Examples from this tutorial:

  • useFetch → reusable data fetching

  • useForm → reusable form handling

5. Optimize Performance with Memoization

  • useCallback: memoizes functions to prevent unnecessary re-renders when passing callbacks to child components.

  • useMemo: memoizes expensive computed values.

const expensiveValue = useMemo(() => computeHeavyValue(data), [data]);
const handleClick = useCallback(() => doSomething(id), [id]);
  • Avoid overusing these hooks; only use them for expensive computations or reference equality issues.

6. Clean Up Effects to Avoid Memory Leaks

Always return a cleanup function from effects that subscribe to external resources, timers, or event listeners:

useEffect(() => {
  const interval = setInterval(() => console.log('tick'), 1000);
  return () => clearInterval(interval); // cleanup
}, []);
  • Prevents multiple timers or event listeners from stacking up.
  • Ensures resources are released when the component unmounts.

7. TypeScript Tips for Hooks

  • Use generics for custom hooks (useFetch<T>()) to get type-safe data.

  • Type reducer actions and state (useReducer<State, Action>()) to prevent runtime bugs.

  • Type event handlers properly for forms:

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { ... };

8. Keep Components Declarative

  • Avoid putting too much logic directly in JSX; delegate to hooks.

  • Keep render logic simple: hooks handle state and side effects, components handle UI.

// ✅ Good
const todos = useTodos(); // hook returns processed data
return <TodoList todos={todos} />;

// ❌ Bad
return <TodoList todos={todos.filter(...).map(...)} />; // logic inside JSX

9. Summary

  • Stick to Rules of Hooks.

  • Keep effects small and focused.

  • Extract reusable logic into custom hooks.

  • Manage dependencies and cleanup carefully.

  • Use TypeScript for type safety in state, actions, and events.

  • Optimize performance only when necessary using memoization hooks.

Following these best practices will make your React applications more predictable, maintainable, and performant.


Conclusion & Next Steps

Congratulations! 🎉 By completing this tutorial, you’ve mastered the core React hooks and learned how to create reusable custom hooks. Here’s a recap and what you can do next.

Key Takeaways

  1. useState

    • Use it for simple state management.

    • Great for single values or small independent states.

  2. useEffect

    • Handles side effects like data fetching, timers, and subscriptions.

    • Always manage dependencies and cleanup to avoid bugs and memory leaks.

  3. useReducer

    • Ideal for complex or multi-value state logic.

    • Centralizes state transitions using pure reducer functions.

  4. Custom Hooks (useFetch, useForm)

    • Encapsulate reusable logic for data fetching, form handling, and more.

    • Promote clean, declarative components and code reuse.

  5. Best Practices

    • Follow the Rules of Hooks.

    • Keep effects small, focused, and cleaned up.

    • Optimize performance when necessary using memoization hooks.

    • Type your hooks and state with TypeScript for safer, predictable code.

Suggested Next Steps

  1. Expand Your Todo App Demo

    • Add validation to the form with useForm.

    • Persist todos in local storage or a backend API.

    • Add filters (completed, pending) using useReducer state.

  2. Explore More Hooks

    • useRef → manage mutable values or access DOM elements.

    • useLayoutEffect → for layout-dependent side effects.

    • useImperativeHandle → expose custom instance methods from a child component.

  3. Combine with State Management Libraries

    • Explore Redux Toolkit or Zustand for larger apps.

    • Compare when to use useReducer vs global state libraries.

  4. Write Your Own Custom Hooks Library

    • Identify repetitive logic in your projects.

    • Package hooks into reusable utilities for future projects.

Final Thoughts

React Hooks unlock a declarative and functional approach to managing state, side effects, and reusable logic. By mastering them:

  • Your components become cleaner, more predictable, and easier to maintain.

  • You can write reusable logic once and share it across your projects.

  • You are better equipped to scale React applications without introducing unnecessary complexity.

Keep practicing by building small projects and refactoring existing class components into functional components with hooks. The more you use hooks, the more natural and powerful they’ll feel.

You can get the full source code on our GitHub.

That's just the basics. If you need more deep learning about React.js, React Native, or related, you can take the following cheap course:

Thanks!