Full-Stack Development · Tutorial 2 of 5
React Hooks for Beginners: useState and useEffect
By the FUTEAC Team · 27 October 2026 · 12 min read · Beginner
In short
A hook is a React function that gives a component an extra capability. useState gives a component memory: it stores a value and a function to change it, and React re-renders the component when that value changes. useEffect runs code after rendering — saving to local storage, for example — and its dependency list controls when it runs again.
- A hook is a function you call inside a component. Hooks only work inside React components (and custom hooks built from them).
- useState returns a pair: the current value and a function to change it. You never change the value directly.
- Changing state re-renders the component. That re-render is how the screen updates — there is no manual DOM update.
- useEffect runs after rendering — for work that reaches outside React: saving data, setting the page title, fetching from an API.
- This tutorial feeds the React from Scratch course (/course/react-from-scratch) and the React Hooks skill (/skill/react-hooks).
What is a hook, and why does React need one?
In the previous tutorial, a component received data through props — and props are read-only. A parent can pass a different title to InfoCard, but InfoCard itself cannot change its own title: data flows down, and a component never edits its own inputs.
But a real app needs things that change: a counter that goes up when you click, a form field that shows what you typed, a list that grows when you add an item. A component cannot do any of that with props alone — it needs memory of its own.
A hook is a plain React function whose name starts with use. You call it inside a component, and it gives that component a capability: useState is memory, and useEffect is side effects — code that runs after rendering, for work that touches the world outside the component.
How does useState work?
useState takes one argument — the starting value — and returns a pair: the current value, and a function that changes it, normally unpacked with array destructuring:
import { useState } from "react";
const [count, setCount] = useState(0);Read it as: "this component has a piece of state called count, it starts at 0, and setCount is the only way to change it." The initial value is used on the first render only.
To change the value, call the setter — never assign to the variable:
<button onClick={() => setCount((c) => c + 1)}>Add one</button>Two things are easy to miss. First, the setter takes a function, not a value: setCount((c) => c + 1) says "take the current count and add one". Each call reads the freshest value, so two fast clicks give +2 — setCount(count + 1) twice reads the same stale count and gives +1. Second, calling the setter re-renders the component: React runs the function again, and the heading showing {count} updates. You never touch the DOM yourself — the re-render is the update.
Expected result: a heading reading "Count: 0", and a button that makes it read "Count: 1", then "Count: 2", one click at a time.

How do I remember the count between visits?
So far the count resets to 0 on every reload. To remember it, the app reads the saved value when it starts (in the initialiser) and saves it whenever it changes (in an effect).
Reading the saved value uses the lazy initialiser form of useState — pass a function, and React calls it once:
function loadSavedCount() {
const saved = localStorage.getItem("futeac-count");
return saved === null ? 0 : Number(saved);
}
const [count, setCount] = useState(loadSavedCount);localStorage is the browser's small key-value store: it keeps strings even after the tab closes. That is why the saved value is converted back with Number(...) and saved with String(count). If nothing has been saved yet, getItem returns null and the count starts at 0.
Saving the value is a side effect — work that reaches outside the component — so it belongs in useEffect:
useEffect(() => {
localStorage.setItem("futeac-count", String(count));
}, [count]);useEffect takes two arguments: the function to run, and a dependency array. This one says: "after every render where count changed, save it." The effect runs after the render has finished, so the screen updates first.
Expected result: click Add three times, reload, and the heading still reads "Count: 3". The state survived because the effect saved it and the initialiser read it back.
What is the dependency array, and what happens if I get it wrong?
The dependency array is the second argument to useEffect, and it decides when the effect runs again:
| Dependency array | When the effect runs |
|---|---|
| *(none — no second argument)* | After every render |
[] | Once, after the first render |
[count] | After the first render, and again whenever count changes |
[count, name] | After the first render, and again whenever either value changes |
Three ways to get it wrong. Leaving the array out entirely when the effect sets state gives an infinite loop: effect → setState → render → effect… and the tab freezes. Using [] when the effect reads a changing value runs it once and leaves it seeing the first value forever — a stale closure; the save effect with [] would keep saving the first count. Leaving a used value out has the same stale-value result. The rule is simple: if the effect reads it, it belongs in the array. The empty array [] means "this effect genuinely depends on nothing that changes" — fetching data once on mount is the classic case, covered by the course lesson "useEffect and Fetching Data".
What are the rules of hooks?
React tracks hooks by call order: the first hook call is always the first hook, the second always the second. Two rules follow, and breaking either one breaks the tracking:
- Only call hooks at the top level of a component. Never inside a loop, a condition, or a nested function.
- Only call hooks from React functions — components, or custom hooks built from other hooks.
This is wrong, and it breaks in a way that is hard to debug:
// Wrong — the hook runs only sometimes
if (showCounter) {
const [count, setCount] = useState(0); // do not do this
}If showCounter is true on one render and false on the next, every hook after this one shifts position, and React hands each hook the wrong stored value. The fix: call the hook unconditionally and use the condition on the result instead:
// Right — the hook always runs; the rendering is conditional
const [count, setCount] = useState(0);
if (!showCounter) return null;Worked example: a counter that remembers
Build the whole app. Only two files change — main.jsx is left exactly as the template created it.
Create the project and start the dev server:
npm create vite@latest hooks-demo -- --template react
cd hooks-demo
npm install
npm run devExpected output: Vite prints a local address, normally http://localhost:5173/. Open it — the template's starter page is about to be replaced.
Replace src/App.jsx with this complete file:
import { useState, useEffect } from "react";
import "./App.css";
function loadSavedCount() {
const saved = localStorage.getItem("futeac-count");
return saved === null ? 0 : Number(saved);
}
export default function App() {
const [count, setCount] = useState(loadSavedCount);
useEffect(() => {
localStorage.setItem("futeac-count", String(count));
}, [count]);
return (
<main className="app">
<h1>Count: {count}</h1>
<p>Click Add. Reload the page — the count is still there.</p>
<div className="buttons">
<button onClick={() => setCount((c) => c + 1)}>Add one</button>
<button onClick={() => setCount((c) => c - 1)}>Take one away</button>
</div>
</main>
);
}Replace src/App.css with this:
.app {
font-family: system-ui, sans-serif;
max-width: 560px;
margin: 40px auto;
padding: 24px;
text-align: center;
}
.app h1 { font-size: 40px; margin: 0 0 8px; }
.app p { color: #444; }
.buttons {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
button {
font-size: 16px;
padding: 10px 18px;
border-radius: 8px;
border: 1px solid #0f766e;
background: #0f766e;
color: #ffffff;
cursor: pointer;
}Save both files — the dev server reloads instantly.
Expected result: a large heading "Count: 0", the instruction line, and two teal buttons. Click Add one three times: the heading reads "Count: 3". Reload: it still reads "Count: 3", because the effect saved it and the initialiser read it back. Click Take one away: the heading drops to "Count: 2".
Check it in DevTools: press F12, open the Application tab, and look under Local Storage — the key futeac-count holds the value 3 (a string, exactly as saved). Change it by hand, reload, and the heading follows the edited value.
Then confirm the production build works:
npm run buildExpected output: Vite reports the build complete with no errors. A failed build would mean a syntax error in App.jsx — the terminal names the file and line.
Try it yourself
Add a Reset button: one line — onClick={() => setCount(0)} — and no other changes, because the effect already saves the new value. Then go further, matching the course lesson "Forms and Controlled Inputs in React": add a text input for a name, stored in its own useState, and show "Hello, {name}" under the heading. When both work, test yourself in the Module Quiz: Hooks and State on the React from Scratch course page (/course/react-from-scratch).
Common mistakes
- Changing state directly.
count++changes a local variable and nothing re-renders — the screen stays the same. State only changes through the setter:setCount(count + 1). - Calling the setter twice with the plain value.
setCount(count + 1)twice in one handler reads the same stalecounttwice and adds 1, not 2. Use the functional form —setCount((c) => c + 1)— whenever the new value depends on the old one. - Forgetting the dependency array. An effect with no array runs after every render. If it also sets state, the tab freezes in an infinite loop. Give every effect its array.
- Doing side-effect work in the render body. Writing
localStorage.setItem(...)directly in the component function runs during rendering — and in development StrictMode renders twice. Effects exist precisely for this work: after render, not during it. - Calling a hook inside a condition or a loop. A conditional hook shifts every hook after it on the next render, and each hook silently gets the wrong stored value. Call hooks unconditionally at the top, and put the condition on what you render.
Frequently asked questions
Do I need useEffect for everything that happens after a click?
No. Click handlers are not effects — the onClick here updates state directly. useEffect is for work that must happen *after rendering* because it touches the outside world: saving data, setting the page title, fetching from an API. A value you can compute from state is plain JavaScript in the render, not an effect.
Can a component have more than one useState?
Yes — one per concern. A form might have const [name, setName] = useState("") and const [email, setEmail] = useState("") side by side, each independent.
Why does my effect run twice in development?
The Vite template renders your app inside React's StrictMode, which deliberately runs effects twice in development to surface effects that are not safe to repeat. It is expected, not a bug — and it is why effects should be idempotent. The production build runs each effect once.
What is the difference between useState and useRef?
useState stores a value and re-renders the component when it changes; useRef stores a value that survives renders *without* re-rendering — useful for something the screen does not show, like a timer id. Both are part of the React Hooks (/skill/react-hooks) skill.
When should I use state and when props?
State lives in the component that owns the data; props flow down from a parent to a child. The count belongs in App's state because App changes it. If a child needed it, App would pass it as a prop — <CounterDisplay count={count} /> — and the child would read it, never change it. That is the same rule from the previous tutorial (/tutorials/full-stack/react-components-and-props): props are read-only inputs. ---
Follow the Full-Stack Developer Roadmap
Build complete web applications — frontend, backend APIs, databases and deployment. Free, step by step, at your pace.
View the Roadmap