- React state must be treated as immutable, with updates performed via setters rather than direct mutation, especially for objects and arrays.
- State updates are asynchronous and may be batched, so using functional updaters avoids stale state issues in timers, closures and rapid interactions.
- Function components with Hooks (useState, useRef and friends) are the modern standard, while tools like React.memo and Immer help with performance and nested data.
- Clear separation of props and state, plus a top-down data flow model, keeps component behavior predictable as applications scale.

State is one of those React concepts that looks simple on the surface, but quickly gets tricky as your app grows. You start with a small counter, then suddenly you are juggling multiple form fields, async updates, nested objects and performance issues when everything re-renders at once. Understanding state deeply is what separates someone who “uses React” from someone who can scale and debug real-world React applications.
In this guide we’ll walk through the current state of state in React (pun intended), from class components and lifecycle methods to modern Hooks and immutable updates. We’ll also dig into subtle but critical topics like asynchronous updates, stale closures, when to use useRef instead of useState, and how to keep your UI predictable. The goal is to give you a clear mental model so your components behave exactly the way you expect.
From props to state: what actually belongs where?
At the core of every React component there are two main data sources: props and state. Props are passed in from the parent component and stay fixed for the lifetime of that render, while state is owned and controlled by the component itself and is meant for data that changes over time.
A good rule of thumb is: if data is configured from outside and doesn’t change in this component, it’s a prop; if the component must track and update it, it’s state. Imagine a blinking text component: the actual text is provided once (a prop), but whether it’s currently shown or hidden toggles continuously (state). This distinction is what lets React keep your data flow predictable and unidirectional.
React encourages a top-down (unidirectional) data flow where state lives in the closest common ancestor that needs to control it. A parent component can hold state and pass values down as props to children, which may render them or transform them but don’t need to know whether those values originally came from state, other props, or were hard-coded.
This is why you’ll often hear that state is “local” or “encapsulated”. Only the component that owns a piece of state can change it, and any UI derived from that state flows downward through props. You can freely combine stateful and stateless (pure) components, and whether something is stateful is considered an implementation detail that can change over time.
Class components: state and lifecycle the old-school way
Before Hooks, the only way to use state and lifecycle methods in React was with ES6 class components. Although most modern apps lean on function components, you’ll still see (and sometimes maintain) class components in many codebases, so it’s worth understanding how they work.
To convert a function component like a simple Clock into a class, you follow a few mechanical steps. You create a class that extends React.Component, add a render() method, move the function body into render, replace props with this.props, and delete the original function. As long as React keeps rendering <Clock /> into the same DOM node, it reuses a single instance of that class.
Adding local state to a class means defining a constructor and assigning an initial this.state object. For example, you might move a date value from props into state by adding a constructor that calls super(props) and sets this.state = { date: new Date() }, then replacing any usage of this.props.date in render() with this.state.date. Remember that in class components you should only assign directly to this.state inside the constructor.
Lifecycle methods are special class methods React calls at specific points in a component’s life. When a component is first inserted into the DOM (mounted), React calls componentDidMount(). When it’s removed (unmounted), React calls componentWillUnmount(). In the classic ticking clock example, you set up a timer in componentDidMount and clear it in componentWillUnmount, storing the timer ID on this (for example this.timerId), and calling this.setState() every second to update the time.
The typical lifecycle for that clock looks like this: React calls the constructor to initialize state, then render() to produce the DOM, then componentDidMount() where you start the timer. Every time the timer fires, you call setState(), which queues an update and triggers render() with the new state. Once the component is removed, componentWillUnmount() clears the timer so you don’t leak resources.
Managing state correctly in classes also means respecting three important rules about setState. You must not mutate this.state directly, you need to remember that updates may be asynchronous and batched, and you should understand that updates are shallowly merged (only top-level state keys are merged, not deep nested objects).
Using state correctly: mutations, async updates and data flow
One of the biggest sources of confusion for beginners is that setState (and the Hook equivalent) does not update state immediately and you should never change state objects in place. React often batches multiple updates together for performance, so both this.state in classes and state variables in Hooks may not reflect the final state right after you schedule an update.
Directly mutating state, like doing this.state.count++ or modifying properties of a state object, skips React’s change detection and can cause components to stay stuck on old values. React expects you to treat any object in state as read-only. Instead of changing existing objects, you create a new object or array with the desired changes and pass that to the state updater.
Because state updates may be asynchronous, you must be careful when calculating the next state from the previous one. In classes, something like this.setState({ count: this.state.count + 1 }) can be wrong if multiple updates are batched. The fix is to use the functional form: this.setState((prevState, props) => ({ count: prevState.count + 1 })). This guarantees you’re working with the latest state snapshot.
The same pattern exists with Hooks: you can call the updater with a function instead of a value. For example, setCount(prev => prev + 1) is the safer way to increment a counter if the new value depends on the previous one or if updates might happen inside timers or event handlers that run later.
Even though state is “local”, the effect of a state change always travels down the component tree. A parent re-render triggered by a state update will also re-render all its children by default. This top-down data flow is fundamental to React’s mental model: one source of truth at the top, UI derived from it below.
Modern React: Hooks and function components
Since React 16.8, Hooks have become the standard way to manage state and side effects in function components. They let you use the same capabilities that class components had (and more) without writing classes or dealing with this and lifecycle methods explicitly, apoyándose en el estado estable de JavaScript moderno.
Function components are now the default style in React codebases. Instead of writing class Example extends React.Component, you define a plain function like function Example() { return <div />; }. When you need state, side effects or refs, you “hook into” React via functions like useState, useEffect and useRef. Hooks can’t be used inside classes and must respect the Rules of Hooks (always call them at the top level of your component, never in loops or conditions).
The useState Hook is the simplest way to add local state to a function component. It takes the initial value as an argument and returns a pair: the current state value and a setter. Thanks to array destructuring you usually write something like const = useState(0). React preserves this state between re-renders, which means the function can be called many times but the state value is remembered.
Unlike class state, the value you keep in useState does not have to be an object. You can store numbers, strings, booleans, arrays or objects—whatever fits the data. If you need multiple independent values, you can call useState several times (for example, age, fruit, todos). Alternatively, you can store a single object and manage multiple properties inside it, but you must respect immutability rules when updating.
When you call the setter function returned by useState, you’re not synchronously changing the value; you’re queuing an update just like with setState in classes. On the next render, React gives your component the new state value. That’s why reading state immediately after calling the setter inside the same synchronous function will still give you the old value.
Managing objects and nested data in state
React lets you put any JavaScript value into state, including objects and arrays, but you must treat them as immutable snapshots. Primitive values like numbers and strings can’t be mutated anyway, but objects and arrays technically can—however, mutating them breaks React’s assumptions and can lead to subtle bugs where components don’t update.
Consider a state object like { x: 0, y: 0 } representing a pointer position. If you write position.x = event.clientX directly, you’ve mutated the existing object. React has no idea the value changed because you never called the setter, so it won’t re-render and your UI stays stuck. The correct approach is setPosition({ x: event.clientX, y: event.clientY }), which creates a brand-new object and tells React to render with that.
Local mutation of freshly created objects is perfectly fine. For example, you can build up a new object step by step: const next = { ...prev }; next.city = 'Paris'; as long as next was not already in state. Mutation becomes a problem only when you change an object that is already being used in some previous state snapshot because other parts of your app may still rely on that old value.
To update only part of an object while keeping the rest, you typically use the object spread syntax. For a form state object like { firstName, lastName, email }, you might handle input changes with something like setPerson({ ...person, : event.target.value }). This copies the old properties, then overwrites just the one that changed. Spread is shallow, so nested objects require more care.
Deeply nested objects can quickly lead to verbose update code, because you need to create new copies along every level of the path you’re changing. For example, if person.artwork.city changes, you’d do setPerson({ ...person, artwork: { ...person.artwork, city: 'London' } }). Under the hood, there is no “nested object”; there are separate objects pointing to each other, so if multiple parents point at the same child object and you mutate it, you’re changing data in more than one place at once.
If you find yourself constantly writing nested spreads, you might consider flattening your state shape or using a helper library like Immer. Immer lets you write code that looks mutative (like draft.artwork.city = 'London') while it produces a new immutable copy for you behind the scenes. In React, you can pair Immer with Hooks via useImmer from the use-immer package.
State in practice: forms, timers and user input
In real-world apps, you rarely manage state just for counters; you manage user input, API responses, and UI “modes” like loading, error, and success. The key mindset shift with React is that you don’t “manipulate the DOM” (for example, “disable this button”); instead, you describe how the UI should look for each state and then update the state.
For example, a quiz or form component might track a status state that toggles between 'typing', 'submitting' and 'success'. The JSX conditionally disables the submit button while submitting, and shows a success message once the answer is correct. You never call imperative DOM methods—React simply re-renders with the new state and the visual output changes.
Handling form fields is where many developers first meet the difference between class state merging and useState behavior. In a class, setState merges the object you pass into the existing state object, so updating one field doesn’t remove the others. With useState, updates replace the entire value: if your state is an object and you call setState({ email: '...' }), any other properties (like password) disappear unless you manually merge them in.
This difference trips people up when they refactor from multiple primitive state variables to a single object. If you change from const and const to const and then write a generic setForm({ : value }), you’ll end up with a state object that only ever has one field. The fix is to spread the previous object: setForm({ ...form, : value }).
In more complex apps, you often won’t be calling setState (or setSomething) directly from everywhere. You might centralize state using libraries like Redux or MobX, or use the useReducer Hook for component-level state machines. In these setups, you still apply the same immutability principles; the only difference is where and how updates are performed.
Re-renders, performance and when to use useRef
Every state update in React triggers a re-render of the component that owns the state and, by default, all of its children. This is by design: re-rendering is how your UI stays in sync with the current data. But it also means that thoughtless state placement can cause unnecessary work and sluggish UIs, especially when child components do expensive calculations or render large lists.
Imagine an app with an input field and a separate component that shows a long list of skills. If the parent component owns both the text the user is typing and the list itself, then every keystroke will re-render the entire tree, including the skill list, even though that list didn’t change. That’s wasted effort.
One simple way to optimize this is by wrapping child components in React.memo. React.memo is a higher-order component that memoizes the result of a function component: if its props are the same between renders, React skips re-rendering it. So your skill list component, once wrapped in React.memo, won’t re-render on every keystroke—only when the skills prop actually changes (for example, when you add a new skill).
Not all “state-like” data belongs in useState; sometimes useRef is the better tool. The useRef Hook gives you a mutable object with a current property that persists for the lifetime of the component, but updating it does not trigger a re-render. That makes it perfect for storing things like timer IDs, DOM element references, or counters that you want to track but don’t need to show in the UI.
A simple example is a counter implemented with useRef instead of useState. If you store the count in countRef.current and increment it in an event handler, the internal value changes, but the displayed JSX won’t update because React didn’t re-render. This illustrates the crucial difference: useState is for values that drive the UI; useRef is for values you want to keep around without affecting rendering.
Immutability and why direct mutation is a trap
A foundational principle in React is that state updates must be immutable. That doesn’t mean you can’t ever change anything; it means that instead of modifying existing values (especially objects and arrays), you create new ones and let the old ones stand as historical snapshots of your UI.
Directly mutating state breaks the connection between your mental model and what React is doing. If you do something like state.count++ or push directly into a state array, React won’t know that anything has changed because you never called the updater function. The internal snapshot React uses to decide when to re-render stays the same, while your code thinks the value changed. That’s how you get bugs that “fix themselves” when you reload.
You also need to avoid assigning a state value to another variable and then mutating that variable. For example, doing const newCount = count; newCount++; still mutates the same underlying value for primitives, and for objects, const copy = stateObj; does not create a copy at all—it just creates another reference to the same object. Proper copying requires patterns like { ...stateObj } for objects or for arrays.
Libraries like Redux, MobX (when configured for immutability), or Immer exist partly to enforce or simplify immutable patterns. Whether you use React’s built-in Hooks or a state management library, the golden rule holds: never mutate existing state in place if you expect React to pick up the change and re-render.
Asynchronous updates, batching and stale state
One subtle but crucial detail about React state is that updates are asynchronous and scheduled, not applied immediately. When you call setState or a Hook setter like setCount, React “enqueues” a re-render for some time in the future. It doesn’t block your code right there to update and re-render immediately, which lets React batch multiple updates and keep performance smooth.
This scheduling model means you can’t rely on reading state immediately after calling the updater inside the same synchronous block. The value you get will usually be the old snapshot. Instead, you should think of the updater as a request: “next time you render, use this value (or this transformation function)”.
This is especially important when you update state based on its current value from within closures like setTimeout or subscription callbacks. Those callbacks capture whatever the state was at the time they were created. If you then do setCount(count + 1) inside a timeout, the count you’re referring to might be stale by the time the callback actually runs.
This phenomenon is known as “stale state” or “stale closures”. For instance, if you have a button that, on click, calls a function which sets a timeout and then increments state after one second, multiple rapid clicks may not increment the state correctly. Each timeout callback uses the old count it captured when the timeout was scheduled.
The robust fix is to use the functional updater form of your state setter. Instead of setCount(count + 1) inside the timeout, you write setCount(prevCount => prevCount + 1). Now each callback receives the latest previous value at the time the update is applied, not the one that happened to be in scope when the timeout was created. This eliminates the stale state issue without changing the behavior of your closures otherwise.
React’s documentation also points out a lesser-known detail: if your functional updater returns nothing (undefined), React will skip re-rendering. That means your updater functions should always return the next state value (or reuse the previous one) unless you explicitly want to prevent an update—something that’s rarely desirable with standard useState usage.
Understanding this combination of asynchronous scheduling, batching, and closure behavior is critical to writing reliable state logic in apps that deal with timeouts, intervals, subscriptions, or rapid user interactions. Once you internalize that state setters schedule updates rather than performing them immediately, bugs that used to feel random will start to make sense.
When you put all of these ideas together—props vs state, class lifecycles vs Hooks, immutability, controlled components, useRef for non-visual values, memoization, async updates and stale closures—you end up with a powerful, predictable model for how React UIs evolve over time. Instead of thinking in terms of imperative DOM changes, you design clear state models and let React handle re-rendering, which makes your components easier to reason about, test, and extend as your application grows.
