React Hooks Made Simple: useState, useEffect, and More

React Hooks Made Simple: useState, useEffect, and More

React Hooks changed the way developers build components. Before Hooks, a lot of React code felt split into two worlds: class components for state and lifecycle methods, and functional components for everything else. That often made projects harder to read, harder to reuse, and harder to teach.

Hooks simplified that world.

Now, with a handful of functions like useState, useEffect, useRef, useMemo, and useCallback, you can build powerful React components without classes. More importantly, you can organize logic in a way that feels natural and reusable. Hooks are not just a feature. They are a style of thinking about React.

This article walks through Hooks in a practical, friendly way. We will start with the basics, then move into real examples, common mistakes, and patterns that make your code cleaner. By the end, Hooks should feel far less mysterious and a lot more useful.


Why Hooks Exist

Before Hooks, React components were often written in two forms:

  • Functional components for simple UI

  • Class components for state, lifecycle methods, and side effects

That split caused several problems. Logic that belonged together ended up separated across lifecycle methods. Reusing stateful logic was awkward. Many developers also found this confusing in class components.

Hooks solved those issues by letting functional components do almost everything class components can do, and often in a cleaner way.

The core idea is simple: Hooks let you “hook into” React features from function components.

That means you can:

  • store local state

  • run side effects

  • access DOM nodes

  • memoize expensive work

  • reuse logic across components

And you can do all of that without leaving the functional component world.


A Quick Look at a Hook

A Hook is just a JavaScript function that starts with the word use.

Example:

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

At first glance, this might look tiny and almost too simple. But there is a lot happening here.

  • useState(0) creates a piece of state with an initial value of 0

  • count holds the current value

  • setCount updates that value

  • when state changes, React re-renders the component

That is the beauty of Hooks: they are compact, expressive, and very close to the problem they solve.


useState: The Hook You Will Use Constantly

useState is usually the first Hook people learn, and for good reason. It handles local component state.

Basic syntax

const [state, setState] = useState(initialValue);

You get two things back:

  • the current state value

  • a function to update it

Example: a simple counter

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  function handleIncrement() {
    setCount(count + 1);
  }

  function handleDecrement() {
    setCount(count - 1);
  }

  return (
    <div>
      <h2>Counter</h2>
      <p>The current value is {count}</p>
      <button onClick={handleDecrement}>-</button>
      <button onClick={handleIncrement}>+</button>
    </div>
  );
}

This is the cleanest form of React state. No classes, no this.state, no binding headaches.

Updating state based on previous state

A very common mistake is writing:

setCount(count + 1);

This works in many cases, but when the new value depends on the old one, the safer approach is the updater function:

setCount(prevCount => prevCount + 1);

That is especially useful when React batches updates.

Example: using the previous value correctly

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  function handleTripleIncrement() {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleTripleIncrement}>Add 3</button>
    </div>
  );
}

If you used setCount(count + 1) three times, you might not get the result you expect. The updater form keeps things consistent.

Storing objects in state

State is not limited to numbers or strings. You can store objects too.

import { useState } from "react";

function ProfileForm() {
  const [profile, setProfile] = useState({
    firstName: "",
    lastName: "",
    email: ""
  });

  function handleChange(e) {
    const { name, value } = e.target;

    setProfile(prev => ({
      ...prev,
      [name]: value
    }));
  }

  return (
    <form>
      <input
        name="firstName"
        value={profile.firstName}
        onChange={handleChange}
        placeholder="First name"
      />
      <input
        name="lastName"
        value={profile.lastName}
        onChange={handleChange}
        placeholder="Last name"
      />
      <input
        name="email"
        value={profile.email}
        onChange={handleChange}
        placeholder="Email"
      />
    </form>
  );
}

Notice the spread operator:

{
  ...prev,
  [name]: value
}

That keeps the other fields intact while updating just one.

Storing arrays in state

Arrays are common in React, especially for lists, carts, messages, tasks, and search results.

import { useState } from "react";

function TodoList() {
  const [todos, setTodos] = useState(["Learn React", "Practice Hooks"]);
  const [newTodo, setNewTodo] = useState("");

  function addTodo() {
    if (!newTodo.trim()) return;

    setTodos(prev => [...prev, newTodo]);
    setNewTodo("");
  }

  return (
    <div>
      <input
        value={newTodo}
        onChange={e => setNewTodo(e.target.value)}
        placeholder="New todo"
      />
      <button onClick={addTodo}>Add</button>

      <ul>
        {todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
    </div>
  );
}

When working with arrays in state, do not mutate them directly with push, pop, or splice. Instead, create a new array. React relies on immutability to detect changes properly.


useEffect: Handling Side Effects in React

If useState is about data, useEffect is about what happens outside the normal rendering flow.

A side effect is anything that affects something outside the component or needs to happen after render, such as:

  • fetching data

  • changing the document title

  • setting up subscriptions

  • using timers

  • listening to events

  • syncing with local storage

Basic syntax

useEffect(() => {
  // side effect code
}, [dependencies]);

The second argument is the dependency array. It tells React when to run the effect.

Example: updating the document title

import { useEffect, useState } from "react";

function PageTitleCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count is {count}
    </button>
  );
}

Every time count changes, the effect runs and updates the browser tab title.

This is a simple example, but it shows the real purpose of useEffect: it keeps React components connected to the outside world.


How the Dependency Array Works

The dependency array is one of the most important concepts in React Hooks.

1. No dependency array

useEffect(() => {
  console.log("Runs after every render");
});

This runs after every render.

2. Empty dependency array

useEffect(() => {
  console.log("Runs only once on mount");
}, []);

This runs only once after the component mounts.

3. With dependencies

useEffect(() => {
  console.log("Runs when count changes");
}, [count]);

This runs whenever count changes.

A real-world fetch example

import { useEffect, useState } from "react";

function UsersList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function fetchUsers() {
      try {
        const response = await fetch("https://jsonplaceholder.typicode.com/users");
        const data = await response.json();
        setUsers(data);
      } catch (error) {
        console.error("Failed to load users:", error);
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();
  }, []);

  if (loading) {
    return <p>Loading users...</p>;
  }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

The empty array means the fetch happens once when the component appears.


Cleanup in useEffect

Sometimes effects need cleanup. That is common with subscriptions, timers, or event listeners.

React lets you return a cleanup function from the effect.

Example: timer cleanup

import { useEffect, useState } from "react";

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return <p>Seconds: {seconds}</p>;
}

The cleanup function runs when the component unmounts. That prevents memory leaks and unwanted background work.

Example: event listener cleanup

import { useEffect, useState } from "react";

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return <p>Window width: {width}px</p>;
}

Without cleanup, the event listener would continue running even after the component is removed.


useRef: Keeping a Mutable Value Without Re-rendering

useRef is useful when you need to hold onto a value without causing a re-render when it changes.

Common use cases

  • accessing DOM elements

  • storing timer IDs

  • keeping previous values

  • tracking mutable values across renders

Example: focusing an input

import { useRef } from "react";

function FocusInput() {
  const inputRef = useRef(null);

  function handleFocus() {
    inputRef.current.focus();
  }

  return (
    <div>
      <input ref={inputRef} placeholder="Type something..." />
      <button onClick={handleFocus}>Focus input</button>
    </div>
  );
}

Here, inputRef.current points to the actual DOM node.

Example: keeping previous value

import { useEffect, useRef, useState } from "react";

function PreviousCount() {
  const [count, setCount] = useState(0);
  const previousCount = useRef();

  useEffect(() => {
    previousCount.current = count;
  }, [count]);

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {previousCount.current}</p>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

Since refs do not trigger re-renders, they are perfect for values that should persist without affecting the UI directly.


useMemo: Optimizing Expensive Calculations

Sometimes your component needs to do a calculation that is expensive. useMemo helps by caching the result until dependencies change.

Example

import { useMemo, useState } from "react";

function ExpensiveCalculation() {
  const [number, setNumber] = useState(1);
  const [darkMode, setDarkMode] = useState(false);

  const factorial = useMemo(() => {
    function computeFactorial(n) {
      let result = 1;
      for (let i = 2; i <= n; i++) {
        result *= i;
      }
      return result;
    }

    return computeFactorial(number);
  }, [number]);

  return (
    <div>
      <button onClick={() => setDarkMode(!darkMode)}>
        Toggle theme
      </button>

      <input
        type="number"
        value={number}
        onChange={e => setNumber(Number(e.target.value))}
      />

      <p>Factorial: {factorial}</p>
    </div>
  );
}

Without useMemo, the calculation could run on every render, even when unrelated state like darkMode changes.

A gentle warning

Do not use useMemo everywhere just because it exists. It is for real performance work, not as a default habit. In many apps, React is already fast enough. Use it when you have a measurable reason.


useCallback: Memoizing Functions

useCallback is similar to useMemo, but it is used for functions.

Basic syntax

const memoizedFunction = useCallback(() => {
  // function body
}, [dependencies]);

Why it matters

In React, functions are recreated on every render. Sometimes that is fine. But if you pass a function as a prop to a child component that uses memoization, repeated recreation can trigger unnecessary renders.

Example

import { useCallback, useState } from "react";

const Button = React.memo(function Button({ onClick, label }) {
  console.log(`Rendering ${label}`);
  return <button onClick={onClick}>{label}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Hassan");

  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  return (
    <div>
      <p>{name}</p>
      <p>{count}</p>

      <Button onClick={increment} label="Increment" />
      <input value={name} onChange={e => setName(e.target.value)} />
    </div>
  );
}

Here, increment stays stable between renders. That can help avoid unnecessary child renders.

Important note

Like useMemo, useCallback is not something to sprinkle everywhere. It is a tool for performance or identity stability, not decoration.


useContext: Sharing Data Without Prop Drilling

When many components need the same data, passing props through several layers can get messy. That is called prop drilling.

useContext helps by letting components read data from a shared context.

Example: theme context

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

const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  function toggleTheme() {
    setTheme(prev => (prev === "light" ? "dark" : "light"));
  }

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

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

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}

export default function App() {
  return (
    <ThemeProvider>
      <ThemeButton />
    </ThemeProvider>
  );
}

This pattern is especially useful for app-wide settings such as:

  • theme

  • authentication

  • language

  • user preferences

  • cart data


useReducer: A Better Fit for Complex State

When your state logic becomes more complex, useReducer can be a cleaner choice than multiple useState calls.

It is especially useful when:

  • several actions can change the state

  • state transitions are predictable

  • the next state depends on the current one in structured ways

Example: a todo reducer

import { useReducer, useState } from "react";

const initialState = [];

function todoReducer(state, action) {
  switch (action.type) {
    case "add":
      return [...state, { id: Date.now(), text: action.payload, done: false }];
    case "toggle":
      return state.map(todo =>
        todo.id === action.payload ? { ...todo, done: !todo.done } : todo
      );
    case "delete":
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, initialState);
  const [text, setText] = useState("");

  function handleAdd() {
    if (!text.trim()) return;
    dispatch({ type: "add", payload: text });
    setText("");
  }

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAdd}>Add Todo</button>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <span
              onClick={() => dispatch({ type: "toggle", payload: todo.id })}
              style={{ textDecoration: todo.done ? "line-through" : "none", cursor: "pointer" }}
            >
              {todo.text}
            </span>
            <button onClick={() => dispatch({ type: "delete", payload: todo.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

This structure makes state changes easier to follow, especially in larger apps.


useLayoutEffect: A More Immediate Effect

useLayoutEffect is similar to useEffect, but it runs synchronously after DOM updates and before the browser paints.

That makes it useful for reading layout or measuring elements before the user sees the screen update.

Example idea

import { useLayoutEffect, useRef, useState } from "react";

function BoxSize() {
  const boxRef = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    if (boxRef.current) {
      setHeight(boxRef.current.offsetHeight);
    }
  }, []);

  return (
    <div>
      <div ref={boxRef} style={{ padding: "20px", border: "1px solid black" }}>
        This box has content.
      </div>
      <p>Box height: {height}px</p>
    </div>
  );
}

In most cases, useEffect is enough. Reach for useLayoutEffect when visual measurement timing matters.


Building Your Own Custom Hooks

This is where Hooks become truly powerful.

A custom Hook is just a JavaScript function that starts with use and uses other Hooks inside. It lets you extract reusable logic from components.

Why custom Hooks matter

Without custom Hooks, you might repeat the same effect logic in multiple places:

  • fetching data

  • form handling

  • local storage sync

  • window size tracking

  • debounce logic

Custom Hooks let you move that logic into a clean reusable function.

Example: useLocalStorage

import { useEffect, useState } from "react";

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

function UsernameForm() {
  const [name, setName] = useLocalStorage("username", "");

  return (
    <input
      value={name}
      onChange={e => setName(e.target.value)}
      placeholder="Enter your name"
    />
  );
}

Now the local storage logic is reusable and neat.

Example: useFetch

import { useEffect, useState } from "react";

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

  useEffect(() => {
    let ignore = false;

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

      try {
        const response = await fetch(url);
        const result = await response.json();

        if (!ignore) {
          setData(result);
        }
      } catch (err) {
        if (!ignore) {
          setError(err.message);
        }
      } finally {
        if (!ignore) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      ignore = true;
    };
  }, [url]);

  return { data, loading, error };
}

Using it:

function Posts() {
  const { data, loading, error } = useFetch("https://jsonplaceholder.typicode.com/posts");

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

  return (
    <ul>
      {data.slice(0, 5).map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

That is the real magic of custom Hooks: they let you build your own small React toolbox.


Rules of Hooks You Should Never Break

Hooks are powerful, but they come with rules. React depends on these rules to keep Hook behavior predictable.

Rule 1: Only call Hooks at the top level

Do not call Hooks inside loops, conditions, nested functions, or callbacks.

Wrong:

if (loggedIn) {
  useEffect(() => {
    console.log("This is wrong");
  }, []);
}

Right:

useEffect(() => {
  if (loggedIn) {
    console.log("This is fine");
  }
}, [loggedIn]);

Rule 2: Only call Hooks from React functions

That means:

  • React function components

  • custom Hooks

Not regular JavaScript functions.

Why these rules exist

React relies on the order of Hook calls. If the order changes between renders, React can no longer match state and effects correctly.

That is why the rules are strict. They are not arbitrary. They protect your app from subtle bugs.


Common Mistakes with Hooks

Even experienced developers make Hook mistakes. These are some of the most common ones.

1. Forgetting dependencies in useEffect

useEffect(() => {
  console.log(name);
}, []);

If name is used inside the effect, it should usually appear in the dependency array.

Correct:

useEffect(() => {
  console.log(name);
}, [name]);

2. Mutating state directly

Wrong:

todos.push("New item");
setTodos(todos);

Right:

setTodos(prev => [...prev, "New item"]);

3. Using useEffect for logic that belongs in event handlers

Not everything belongs in an effect. For example, showing a message when a button is clicked should usually happen in the click handler, not in useEffect.

4. Overusing useMemo and useCallback

These Hooks are useful, but they also make code more complex. Use them when they help, not just because you can.

5. Creating infinite loops in effects

Example:

useEffect(() => {
  setCount(count + 1);
}, [count]);

This updates count, which causes the effect to run again, which updates count again. That creates an infinite loop.


When to Use Which Hook

A simple way to think about it:

  • useState: store local data

  • useEffect: run side effects after render

  • useRef: hold values without re-rendering, or access DOM

  • useMemo: cache expensive calculations

  • useCallback: cache function references

  • useContext: share data across components

  • useReducer: manage complex state transitions

  • useLayoutEffect: measure layout before paint

  • custom Hooks: reuse logic

This mental map makes Hooks easier to choose.


A Small Real Example: Search and Filter

Let’s build something practical: a small searchable list.

import { useMemo, useState } from "react";

const products = [
  "Laptop",
  "Phone",
  "Headphones",
  "Keyboard",
  "Mouse",
  "Monitor",
  "Tablet"
];

function ProductSearch() {
  const [query, setQuery] = useState("");

  const filteredProducts = useMemo(() => {
    return products.filter(product =>
      product.toLowerCase().includes(query.toLowerCase())
    );
  }, [query]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search products"
      />

      <ul>
        {filteredProducts.map(product => (
          <li key={product}>{product}</li>
        ))}
      </ul>
    </div>
  );
}

This example shows a nice combination of Hooks:

  • useState for the search term

  • useMemo for the filtered list

It is simple, but it feels like real app code.


Another Real Example: Toggle Theme with Context and State

Here is a classic pattern for small app-wide settings.

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

const ThemeContext = createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  const toggleTheme = () => {
    setTheme(prev => (prev === "light" ? "dark" : "light"));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      <div className={theme}>
        {children}
      </div>
    </ThemeContext.Provider>
  );
}

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

  return (
    <button onClick={toggleTheme}>
      Switch to {theme === "light" ? "dark" : "light"} mode
    </button>
  );
}

function App() {
  return (
    <ThemeProvider>
      <ThemeSwitcher />
    </ThemeProvider>
  );
}

This is the kind of structure that makes a real application feel organized without overengineering it.


Thinking Like a React Developer

One of the biggest mindset shifts with Hooks is learning to think in terms of data flow and effects rather than lifecycles and classes.

That means asking:

  • What data does this component need?

  • What should happen when that data changes?

  • What side effect should run after render?

  • What state belongs here versus in a parent component?

  • Is this logic reusable enough to become a custom Hook?

These questions help you design better components.

A good React component is usually small, focused, and easy to reason about. Hooks help you keep it that way.


A Mini Project: Notes App with Hooks

Let’s bring several Hooks together in one simple notes app.

import { useEffect, useState } from "react";

function NotesApp() {
  const [notes, setNotes] = useState(() => {
    const saved = localStorage.getItem("notes");
    return saved ? JSON.parse(saved) : [];
  });
  const [text, setText] = useState("");

  useEffect(() => {
    localStorage.setItem("notes", JSON.stringify(notes));
  }, [notes]);

  function addNote() {
    if (!text.trim()) return;
    setNotes(prev => [...prev, { id: Date.now(), text }]);
    setText("");
  }

  function deleteNote(id) {
    setNotes(prev => prev.filter(note => note.id !== id));
  }

  return (
    <div>
      <h2>My Notes</h2>

      <textarea
        value={text}
        onChange={e => setText(e.target.value)}
        placeholder="Write a note..."
      />

      <button onClick={addNote}>Save Note</button>

      <ul>
        {notes.map(note => (
          <li key={note.id}>
            {note.text}
            <button onClick={() => deleteNote(note.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

This little app uses:

  • useState for notes and text input

  • useEffect to save notes to local storage

  • functional updates to avoid stale state issues

This is a great example of how Hooks help you build something useful without making the code hard to follow.


Best Practices for Clean Hook Code

A few habits make Hook-based code much easier to maintain.

Keep state close to where it is used

Do not lift state higher than necessary. If a value is only used in one component, keep it there.

Split logic into custom Hooks

If you see the same effect logic more than once, extract it.

Avoid giant components

Hooks work best when components stay focused. A component that tries to do everything becomes hard to maintain.

Use the dependency array honestly

Do not “cheat” by removing dependencies just to silence warnings unless you fully understand the implications.

Prefer readable code over clever code

Hooks are meant to make React easier, not more mysterious.


Hooks and Human-Friendly Code

One reason many developers enjoy Hooks is that they make code feel more like plain JavaScript again.

You can read a component top to bottom and understand:

  • what state it keeps

  • what happens when that state changes

  • what side effects run

  • what gets rendered

That readability matters. Code is written once, but read many times. Hooks help you write code that your future self will thank you for.


Final Thoughts

React Hooks are not just a replacement for class component features. They are a cleaner way to structure React logic.

useState gives you local state.
useEffect lets you synchronize with the outside world.
useRef keeps mutable values and DOM references.
useMemo and useCallback help with performance and identity.
useContext reduces prop drilling.
useReducer helps with more complex state.
Custom Hooks let you package reusable behavior into something elegant.

The more you use Hooks, the more natural they become. At first, they may look like a set of separate tools. Over time, they start to feel like one flexible system for building modern React apps.

That is the real value of Hooks: they help you write components that are easier to understand, easier to reuse, and easier to grow.

React became more powerful when Hooks arrived, but it also became more human. And that is a very good thing.


Quick Practice Ideas

Try building a few small components using Hooks:

  • a counter with increment, decrement, and reset

  • a form with live input tracking

  • a theme toggle with context

  • a timer with cleanup

  • a todo list with useReducer

  • a search box with filtered results

Each one will help the ideas settle in faster than reading alone.


One Last Example: Counter with Reset

Here is a clean final example that ties together the basics:

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h2>Counter</h2>
      <p>{count}</p>

      <button onClick={() => setCount(prev => prev - 1)}>-</button>
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount(prev => prev + 1)}>+</button>
    </div>
  );
}

Simple. Clear. Useful.

That is often the best style of React code.