State
Why a plain variable doesn't work
The instinct: just track it with a normal variable and mutate it on click.
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:
hasWatchedis just a normal local variable. Every timeMovieCardrenders, the whole function body runs again from the top, andlet hasWatched = falseresets it straight back tofalse.- Mutating a variable doesn't tell React to do anything. React only re-renders when it's told to. Changing
hasWatchedinside the click handler doesn't trigger a re-render on its own, so even the one moment wherehasWatchedreally istruenever makes it to the screen. - Even if something else happened to cause a re-render, it still wouldn't matter: the next time
MovieCardruns,hasWatchedgets declared fresh again and lands right back onfalse.
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.
const [hasWatched, setHasWatched] = useState(false)hasWatchedis the current value. Read it like any other variable.setHasWatchedisn't "the thing that changes the variable." Calling it doesn't mutatehasWatcheddirectly, 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.
function MovieCard() {
const [hasWatched, setHasWatched] = useState(false)
return (
<button>
{hasWatched ? "Watched" : "Not watched"}
</button>
)
}Walk through what actually happens:
- First render:
hasWatchedstarts asfalse, 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
hasWatchedistrue, 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.
<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 goesfalse → true → true, that extratrueis 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.
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>
)
}Appowns themoviesstate.MovieCardnever touchessetMoviesdirectly, all it gets is amovieto display and anonWatchedfunction 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 justhandleWatched(movie.id)running back inApp, since that's the functionApphanded down.
Reading setMovies(movies.map(...))
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 originalmoviesarray, 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 offmovieinto a new object, then overwrites justwatchedwith its opposite.title,genre,rating,idall 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 = truein 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.