all topics

State

Why a plain variable doesn't work

The instinct: just track it with a normal variable and mutate it on click.

jsx
function MovieCard({ movie }) {
    let hasWatched = false
    let disabled = false
 
    return (
        <div>
            <h2>{movie.title}</h2>
            <p>{movie.genre}</p>
            <p>Rating: {movie.rating}</p>
 
            <button
                disabled={disabled}
                onClick={() => {
                    alert("Clicked!")
                    hasWatched = true
                    console.log(hasWatched)
                }}
            >
                {hasWatched ? "Watched" : "Not watched"}
            </button>
        </div>
    )
}

Clicking the button logs true, so the mutation is real, but the label on the button never changes. Two separate problems stack up here:

  • hasWatched is just a normal local variable. Every time MovieCard renders, the whole function body runs again from the top, and let hasWatched = false resets it straight back to false.
  • Mutating a variable doesn't tell React to do anything. React only re-renders when it's told to. Changing hasWatched inside the click handler doesn't trigger a re-render on its own, so even the one moment where hasWatched really is true never makes it to the screen.
  • Even if something else happened to cause a re-render, it still wouldn't matter: the next time MovieCard runs, hasWatched gets declared fresh again and lands right back on false.

So the click handler runs, the variable does change for an instant, and none of it ever reaches the UI. That's the exact gap useState exists to close: a way to store a value that survives across renders, and a way to tell React "this changed, re-render."

What state actually is

State is just data a component owns that's allowed to change over time, and that React actually pays attention to.

jsx
const [hasWatched, setHasWatched] = useState(false)
  • hasWatched is the current value. Read it like any other variable.
  • setHasWatched isn't "the thing that changes the variable." Calling it doesn't mutate hasWatched directly, it tells React "here's a new value for this piece of state, please re-render with it."

That distinction matters more than it sounds like it should. setHasWatched(true) isn't really an assignment, it's a request.

What a render actually is

A render just means React ran your component function again to figure out what the UI should look like right now, top to bottom.

jsx
function MovieCard() {
    const [hasWatched, setHasWatched] = useState(false)
 
    return (
        <button>
            {hasWatched ? "Watched" : "Not watched"}
        </button>
    )
}

Walk through what actually happens:

  • First render: hasWatched starts as false, the component runs, the button says "Not watched".
  • You click it, which calls setHasWatched(true).
  • React schedules an update, and runs the component again.
  • This time hasWatched is true, so the button now says "Watched".

The thing worth holding onto: a render isn't the same thing as a state change. The state change is what triggers the render, but the render itself is just React recalculating the UI by running your function again.

Strict Mode can render twice, that's not a bug

In development, with <StrictMode> wrapping the app, you might notice a component logging twice for what feels like one state change.

jsx
<StrictMode>
    <App />
</StrictMode>
  • That's Strict Mode intentionally running rendering-related code twice, on purpose, in development only, to help surface bugs that only show up when something isn't supposed to run more than once.
  • It's not two separate state changes. The state itself still only goes false → true, once. It's the rendering that goes false → true → true, that extra true is just React's dev-mode double-check.
  • Leave Strict Mode on. It's there to catch mistakes before they become real bugs, not to warn you that something's already wrong.

Lifting the state up to the parent

The state moves out of MovieCard entirely and into App. MovieCard stops owning anything, it just displays a movie it was given, and calls a function it was given when its button is clicked.

jsx
import { useState } from "react"
 
function App() {
    const [movies, setMovies] = useState([
        { id: 1, title: "Dune", genre: "Sci-Fi", rating: 5, watched: false },
        { id: 2, title: "Her", genre: "Drama", rating: 4, watched: false },
        { id: 3, title: "Parasite", genre: "Thriller", rating: 5, watched: false }
    ])
 
    function handleWatched(id) {
        setMovies(movies.map(movie =>
            movie.id === id
                ? { ...movie, watched: !movie.watched }
                : movie
        ))
    }
 
    return (
        <div>
            <Header />
            {movies.map(movie => (
                <MovieCard key={movie.id} movie={movie} onWatched={handleWatched} />
            ))}
        </div>
    )
}
 
function MovieCard({ movie, onWatched }) {
    return (
        <div>
            <h2>{movie.title}</h2>
            <button onClick={() => onWatched(movie.id)}>
                {movie.watched ? "Watched" : "Not watched"}
            </button>
        </div>
    )
}
  • App owns the movies state. MovieCard never touches setMovies directly, all it gets is a movie to display and an onWatched function to call. This is the "data down, events up" pattern from earlier, finally showing up in real code: the callback is how a child asks its parent to do something, without ever reaching up into the parent itself.
  • Clicking a card's button calls onWatched(movie.id), which is really just handleWatched(movie.id) running back in App, since that's the function App handed down.

Reading setMovies(movies.map(...))

jsx
setMovies(
    movies.map(movie =>
        movie.id === id
            ? { ...movie, watched: !movie.watched }
            : movie
    )
)

In words: build a brand new movies array. For the movie whose id matches the one that was clicked, make a copy of it with watched flipped. Leave every other movie exactly as it was. Hand this whole new array to React as the new state.

  • .map() always returns a new array, so this never mutates the original movies array, it produces a full replacement of it.
  • The ternary decides, per movie, whether to replace it or pass it straight through: movie.id === id ? changed copy : same movie, untouched.
  • { ...movie, watched: !movie.watched } copies every existing field off movie into a new object, then overwrites just watched with its opposite. title, genre, rating, id all come along unchanged.
  • React needs a genuinely new array (and, for the one item that changed, a genuinely new object) to reliably notice something's different. Reaching into the array and doing movies[i].watched = true in place is the array/object version of the exact problem from the very first section, mutating something quietly doesn't tell React to re-render.