React Notes
React component names must start with an uppercase letter; otherwise, they will not run.
In JSX syntax rules, there's a clear and crucial distinction regarding the capitalization of tag names. Specifically, when you write a tag in JSX, if its first letter is uppercase, the JSX compiler will recognize it as a custom React component. This means it expects to find a component with the same name (e.g., a functional or class component) imported via import
or defined within the current scope in your JavaScript code, and it will attempt to render the UI logic defined by that component.
Conversely, if a tag's first letter is lowercase, JSX treats it as a standard, native HTML tag, such as <h1>
, <p>
, <section>
, etc. In this case, JSX directly transforms it into the corresponding HTML element, which is then rendered by the browser.
This distinction mechanism is key to how JSX understands and handles the relationship between custom components and native DOM elements. It ensures React can correctly parse your code and translate your component tree into a DOM structure that the browser understands. Therefore, strictly adhering to this naming convention is essential for writing runnable and understandable React applications; mixing cases will lead to rendering errors or unexpected behavior.
Components can render other components, but you should not define components inside other components.
JSX Rules
1. Return a single root element.
2. Tags must be closed.
Self-closing tags like <img>
must be written as <img />
, and elements with only an opening tag like <li>apple
must have a closing tag, such as <li>oranges</li>
.
3. Use camelCase for most attributes.
Since JSX attributes are transformed into JavaScript object key-value pairs, and JavaScript variable names cannot contain hyphens (-
) or be reserved words like class
, React uses camelCase for most HTML and SVG attributes, e.g., strokeWidth
instead of stroke-width
.
class
is a reserved word and thus requires className
instead, which also aligns with DOM property naming conventions.
However, aria-*
and data-*
attributes retain their hyphenated HTML format for historical reasons.
function Profile(props) {
return (
<div className="card">
<Avatar {...props} />
</div>
);
}
This code forwards all Profile
's props to Avatar
.
<Card>
<Avatar />
</Card>
The Card
component will receive a children
prop set to <Avatar />
.
function Card({ children }) {
return (
<div className="card">
{children}
</div>
);
}
The shorthand Fragment syntax <> </>
does not accept a key
prop. Therefore, you have only two options: either wrap the generated nodes in a <div>
tag, or use the slightly longer but more explicit Fragment
full syntax:
import { Fragment } from 'react';
// ...
const listItems = people.map(person =>
<Fragment key={person.id}>
<h1>{person.name}</h1>
<p>{person.bio}</p>
</Fragment>
);
The Fragment
tag itself will not appear in the DOM. This code will ultimately transform into a list of <h1>
, <p>
, <h1>
, <p>
... elements.
A pure function is a special type of function that satisfies two core conditions: first, for the same input parameters, it always returns the exact same output, without changing based on external state; second, a pure function produces no observable side effects, meaning it does not modify any state outside the function, such as not modifying passed-in arguments, not modifying global variables, not performing network requests, and not printing to the console. In functional programming paradigms like React, pure functions are fundamental for building predictable, testable, and understandable components.
React provides "Strict Mode," which, when developing, will call each component function twice. By repeatedly calling component functions, Strict Mode helps find components that violate these rules.
Strict Mode does not take effect in production environments, so it does not slow down the application. To enable Strict Mode, you can wrap your root component with <React.StrictMode>
. Some frameworks do this by default.
Local Mutation
During rendering, a component modifies the value of a pre-existing variable. To describe this more precisely, we call this phenomenon mutation. Pure functions do not modify variables outside their function scope, nor do they modify objects that existed before the function call (before component rendering)—doing so would make the function impure!
However, you are absolutely free to modify variables and objects you've just created during rendering. In this example, you create an empty []
array, assign it to the cups
variable, and then push
100 Cup
components into it:
function Cup({ guest }) {
return Tea cup for guest #{guest};
}
export default function TeaGathering() {
const cups = [];
for (let i = 1; i <= 100; i++) {
cups.push();
}
return cups;
}
If this cups
array were created outside the TeaGathering
function, that would be a significant problem! Because doing so would cause you to modify a pre-existing object when calling the array's push
method.
However, here it's not an issue because the cups
array is created inside the TeaGathering
function with each render. Code outside the TeaGathering
function will not perceive this change. This is called local mutation—like a little secret hidden inside the component.
React only expects you to write pure functions during the "render phase"; other logic can be freely implemented.
Why does React emphasize pure functions?
Writing pure functions requires adhering to certain habits and conventions, but it also unlocks immense possibilities:
- Components can run safely in different environments. Because pure functions always return the same result for the same input, React components can run seamlessly in browsers, on servers, or even on other platforms. For example, during Server-Side Rendering (SSR), a component can safely respond to multiple user requests.
- Rendering can be safely skipped. If a component's inputs haven't changed, React can skip re-rendering. This is a safe optimization because the return value of a pure function is always deterministic, allowing for reliable caching of results and significantly improving performance.
- The rendering process can be interrupted and restarted at any time. When rendering a deep component tree, if some data changes, React can interrupt the current render and restart from scratch without worrying about side effects or state pollution. The determinism of pure functions guarantees this "stop anytime, restart anytime" capability.
- React's core capabilities rely on pure function design. Every new feature we are building—from data fetching and animation handling to performance optimization—leverages the purity of components. Keeping components pure is key to fully unleashing the potential of the React programming paradigm.
If a component produces side effects during the rendering phase, then when React interrupts and restarts rendering due to data updates, these side effects may have already affected external state, leading to unpredictable final render results and even bugs.
If the rendering process has side effects, it cannot be safely interrupted and resumed.
Data that continuously changes and updates over time is commonly referred to as "state." This state reflects the immediate situation or conditions of a system or program at a specific moment, representing its current dynamic properties.
Event handlers are characterized by:
- Typically defined inside your component.
- Names starting with
handle
, followed by the event name.
By convention, event handlers are usually named handle
followed by the event name. You'll frequently see onClick={handleClick}
, onMouseEnter={handleMouseEnter}
, etc.
In React, when passing a function to an event handler (like onClick
), you should pass the function's reference directly, not call the function. This means you need to pass the function itself as the attribute value, rather than executing the function and passing its return value.
Correct ways:
Passing a defined function:
tsx<button onClick={handleClick}>
- Here,
handleClick
is a reference to a function. React will remember this function and execute it only when the user clicks the button.
- Here,
Passing an inline anonymous function:
tsx<button onClick={() => alert('...')}>
- Here,
() => alert('...')
is a reference to an anonymous function. This anonymous function will be called when the user clicks the button, thereby executingalert
.
- Here,
Incorrect ways:
Directly calling a defined function:
tsx<button onClick={handleClick()}>
- The
()
inhandleClick()
causes the function to execute immediately when the component renders, not when clicked. This is because JavaScript inside JSX{}
is evaluated immediately. You are passing the return value ofhandleClick
, not the function itself.
- The
Directly calling an inline function:
tsx<button onClick={alert('You clicked me!')}>
alert('You clicked me!')
will execute immediately when the component renders, not when clicked. Similarly, code inside JSX{}
is executed immediately. You are passing the return value of thealert
function (which is typicallyundefined
in a browser environment) toonClick
, not a function.
The key difference is:
When you use ()
to call a function, you are passing the result of that function's execution. When you don't use ()
and directly pass the function name or an anonymous function, you are passing a reference to the function, and React will call the function pointed to by that reference at the appropriate time (e.g., when the user clicks).
Syntax for preventing event propagation
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
export default function Toolbar() {
return (
<div className="Toolbar" onClick={() => {
alert('You clicked the toolbar!');
}}>
<Button onClick={() => alert('Playing!')}>
Play Movie
</Button>
<Button onClick={() => alert('Uploading!')}>
Upload Image
</Button>
</div>
);
}
Capture Phase Events
In rare cases, you might need to capture and handle an event before it reaches its target element, even if the event handler on the target element prevents further propagation. This is like an "event interceptor"—we get a chance to process the event as it travels downwards.
For example, suppose you want to log data for every click on the page (e.g., for analytics), regardless of whether a specific button clicked prevents event bubbling (stopPropagation()
). In this scenario, traditional onClick
events wouldn't suffice, because if a child element stops propagation, the parent's onClick
wouldn't receive it.
To achieve this "early interception" capability, React provides capture phase events. You just need to append Capture
to the event name:
<div onClickCapture={() => { /* This will execute first */ }}>
<button onClick={e => e.stopPropagation()} /> {/* This button will stop propagation */}
<button onClick={e => e.stopPropagation()} /> {/* This button will also stop propagation */}
</div>
In this example:
- When a user clicks any
<button>
, theonClickCapture
handler will be called before the event reaches the button and executes the button'sonClick
. - Even if the button's
onClick
callse.stopPropagation()
to prevent the event from bubbling up to thediv
'sonClick
handler,onClickCapture
will have already executed.
Three phases of event propagation (simplified understanding):
An event, from trigger to completion, typically goes through these three phases:
- Capture Phase: The event starts from the document root and propagates downwards layer by layer until it reaches the target element. During this process, all event handlers with the
Capture
suffix (e.g.,onClickCapture
) are triggered. - Target Phase: The event reaches the actual element that was clicked. At this point, the ordinary event handler bound to that element (e.g.,
onClick
) is triggered. - Bubbling Phase: The event starts from the target element and bubbles upwards layer by layer until it reaches the document root. During this process, all event handlers without the
Capture
suffix (e.g.,onClick
on parent elements) are triggered, unless stopped bystopPropagation()
at some stage.
When to use capture events?
Capture events are primarily used for implementing "global" or "infrastructure" level functionalities, such as:
- Routing: Intercepting and handling route transitions before the event reaches link elements.
- Data Analytics / Tracking: Recording all user interaction behaviors on a page, without needing to care about specific element event handling logic.
- Event Delegation for specific scenarios: Capturing all specific events from children at a parent level for unified processing.
In summary, capture events are the "early batch" for event processing. They ensure that even if a child element prevents event bubbling, you still have an opportunity to handle the event before it reaches the target element. In most common application business logic, you might not directly use them often, but they are very useful when building complex systems or infrastructure.
export default function Signup() {
return (
<form onSubmit={e => {
e.preventDefault();
alert('Submitting form!');
}}>
<input />
<button>Send</button>
</form>
);
}
e.stopPropagation()
prevents event handlers bound to outer tags from being triggered.e.preventDefault()
prevents the default browser behavior for a few events.
Event handlers are the best place to perform side effects. Unlike render functions, event handlers can perform any operation, including modifying the DOM, making network requests, setting state, etc., so they do not need to be pure functions.
The useState
Hook provides two features:
- A state variable to preserve data between renders.
- A state setter function to update the variable and trigger React to re-render the component.
In React, useState
and any other function starting with use
are called Hooks. They can only be called at the top level of a component or a custom Hook, not inside conditional statements, loops, or other nested functions.
When you call useState
, you are telling React that you want this component to remember something:
const [index, setIndex] = useState(0); // You want React to remember index.
The only argument to useState
is the initial value of the state variable. In the example, the initial value of index
is set to 0
by useState(0)
.
Every time your component renders, useState
gives you an array containing two values:
- The state variable (
index
) will hold the value from the last render. - The state setter function (
setIndex
) can update the state variable and trigger React to re-render the component.
useState
Workflow:
- Initial Render: When the component renders for the first time, if you call
useState(0)
like this, it returns an array[0, setIndex]
. At this point, React remembers that the initial value of this component'sindex
state is0
. - Updating State: Suppose a user clicks a button, triggering an interaction like a form submission, and the event handler calls
setIndex(index + 1)
. In this specific call, the current value ofindex
is0
, so it's effectivelysetIndex(0 + 1)
, which issetIndex(1)
. This operation notifies React to remember that the latest value ofindex
should now be1
. More importantly, this notification triggers a new render. - Subsequent Renders: The component now performs its second render. Although your component code still has
useState(0)
, React doesn't simply setindex
back to0
. Instead, because React has remembered that you previously updated theindex
value viasetIndex(1)
, it intelligently returns[1, setIndex]
. At this point, theindex
variable used inside the component is the latest value,1
. - And so on: with each render, the first value from
useState
will return the latest value you previously updated via theset
function.
Core Points:
useState(initialValue)
is only used to set the initial default value of the state during the component's first render.- Once the state is updated (via the
set
function), React secretly "remembers" this updated latest value. - In subsequent renders, even if
useState(initialValue)
is executed again, React will not use the initial value; instead, it will return the latest state value it has remembered. - The
set
function not only updates the state but also triggers a re-render of the component, ensuring the UI reflects the latest data.
State is internal to a component instance, so even if you reuse a component, the state of each component instance is independent and does not interfere with others. This is the difference between state and ordinary variables: ordinary variables are global and can be accessed and modified by all components, leading to data inconsistencies.
There are two reasons a component might render: the component's initial render, or the component's (or one of its ancestors') state has changed.
The initial render typically occurs when the application starts, by calling createRoot
with the root element, and then calling render
to render the component. The render
method's argument is the root component, usually the App
component.
After the initial render, you can update the state using setXXX
functions, which triggers subsequent renders. Updating a component's state adds a render to the queue.
During rendering, React calls your component functions. The initial render calls the root component. For subsequent renders, React calls the functional components whose internal state updates triggered the render.
When a component's state updates and triggers a re-render:
- Component returns new JSX: React calls the component's function body and gets the new JSX structure it returns.
- Recursively renders child components: If this new JSX contains other components (i.e., nested child components), React will continue to render these child components. It will sequentially call each child component's function body to get their returned JSX.
- Deepens layer by layer until leaf nodes: This process will continuously and recursively go deeper until all nested components have been processed, finally reaching "leaf" nodes that only return HTML elements (like
<div>
,<span>
, etc.) and no longer contain other React components.
Rendering must be a pure computation, meaning rendering a component must be a pure function and have no side effects; otherwise, it will lead to unpredictable rendering results.
If an updated component is very high in the tree, the default behavior of rendering all nested components inside the updated component will not achieve optimal performance.
In a React component tree, components have parent-child relationships. A "high-level" component usually means it's a root component, a top-level layout component, or a large container component. When the state of such a high-level component or its received props change, React assumes it needs to re-render.
React's default rendering behavior is recursive. This means that when a parent component is triggered to re-render, React will by default re-render all of its child components, and their child components, and so on, down to the deepest level of the component tree. Even if some child components' props haven't actually changed, they will still be re-rendered.
function App() { // High-level component
const [count, setCount] = useState(0);
return (
<div>
<Header /> {/* Will re-render even if Header doesn't need updating */}
<Sidebar /> {/* Will re-render even if Sidebar doesn't need updating */}
<MainContent count={count} /> {/* MainContent does need updating */}
<Footer /> {/* Will re-render even if Footer doesn't need updating */}
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
In this App
component, every time count
changes, App
re-renders. Its default behavior is that Header
, Sidebar
, MainContent
, and Footer
will all be re-rendered, even if the JSX output of Header
, Sidebar
, and Footer
doesn't change at all.
"Optimal performance" here means only performing necessary rendering work. If a component's JSX output (the UI it renders) has not changed in the current render cycle, then re-rendering it is an unnecessary performance overhead.
Solutions:
React.memo
(for functional components): Wraps a functional component to make it re-render only when its props change.PureComponent
(for class components): Similar toReact.memo
, it decides whether to re-render by shallowly comparing props and state.shouldComponentUpdate
(for class components): Developers can manually implement this lifecycle method to customize rendering conditions.
After the component renders (function call), DOM modification begins. For the initial render, the appendChild()
method is called to place all created DOM nodes on the screen. For re-renders, the minimum necessary operations (calculated during rendering, using a diffing algorithm) are applied to ensure the DOM matches the latest render output.
The DOM is only updated when there are differences between renders.
Suppose there's a Clock
component that receives a time
prop, and this time
value is passed down from its parent component every second, triggering the Clock
component to re-render:
export default function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
It's noteworthy that in this component, even though the Clock
component re-renders every second, you can still type text into the <input>
tag, and the text you type (value
) will not disappear with each component re-render.
This perfectly demonstrates the elegance of React's Reconciliation mechanism. Each time the Clock
component re-renders due to a time
prop update, React doesn't crudely destroy old DOM elements and re-create all new ones. Instead, it performs the following operations:
- Compares new and old JSX trees: React compares the virtual DOM tree generated by the current render (the new JSX structure) with the virtual DOM tree from the previous render.
- Updates the
<h1>
tag: For the<h1>
tag, React detects that its internal text content ({time}
) has changed. Therefore, it precisely updates thetextContent
of the<h1>
tag in the real DOM to display the latesttime
value. - Handles the
<input>
tag: This is the crucial point. React finds that the<input>
tag appears in the new JSX at the exact same position as in the previous render, and its "type" hasn't changed. Since the<input>
tag is a DOM element affected by user interaction (it manages its own internal state, such as the user-enteredvalue
), React intelligently determines that there's no need to modify the<input>
tag itself, or even touch itsvalue
attribute, unless you explicitly bind it viavalue
props or akey
prop.
Therefore, React's optimized behavior ensures that: it only updates the parts that have actually changed, while preserving the internal state of DOM elements that haven't substantially changed (like the <input>
in this example), thereby avoiding unnecessary DOM operations, improving rendering efficiency, and greatly enhancing user experience. Users won't lose input focus or already entered content due to component re-renders.
After rendering is complete and React has updated the DOM, the browser begins to repaint the screen (browser rendering).
A React application's screen update (state update) process: Trigger -> Render -> Commit.
If the render result is the same as the previous one, React will not modify the DOM.
State variables are similar to regular JavaScript variables, but modifying a state variable doesn't change its original value. Instead, it creates a snapshot, which React remembers and uses for the next render.
The essence of React rendering is to generate snapshots of the UI. Rendering is the process of calling your component function. When your component is called, it returns a JSX structure, which is a snapshot of your user interface at a specific point in time.
This "snapshot" is highly precise and valid at that moment:
- Component's props: These are the values passed in by its parent component during the current render cycle.
- Event handlers: These are function instances defined based on the current render's state and props.
- Internal variables: All variables defined within the component function's scope will have their values calculated and locked based on the state and props at the time of this render. They form the context for this specific render.
Unlike static photos or movie frames, the UI "snapshot" generated by React is dynamic and interactive. It not only depicts the user's visual interface but also embeds interactive logic, most typically event handlers. This logic clearly defines how the UI responds to user input (e.g., clicks, typing, etc.).
Once React receives this new UI snapshot, it takes on the following responsibilities:
- Updating the screen: React intelligently compares the new snapshot with the previous screen state (through its efficient reconciliation algorithm) and only updates the necessary parts of the real DOM to ensure the UI on the screen precisely matches the latest snapshot.
- Binding events: Simultaneously, React binds (or updates) event handlers on the corresponding DOM elements. Thus, when a user clicks a button, the on-screen action will accurately trigger the click event handler you defined for that button in that specific render.
React Re-rendering Process:
When an application's state
or props
change, triggering a component re-render, React follows a clear cycle:
- Calls your component function again: React re-executes your component as a pure function.
- Generates a new JSX snapshot: Based on the latest
state
andprops
, the function calculates and returns a new JSX structure, which is the new snapshot of the application's UI. - Updates the user interface: React is responsible for efficiently reflecting this new UI snapshot onto the user's screen, ensuring the interface always synchronizes with the latest data.
State's "Memory" Characteristic:
Distinguishing between state
and regular variables is crucial. Regular variables inside a component have their lifecycle end once the function finishes execution and returns JSX; they will be re-initialized on the next render. However, state
is different; it's the component's "memory":
The value of state
is not "living" inside your component function. Instead, it is actually managed and "stored" by the React runtime itself—you can imagine it as being placed on a "shelf" inside React.
When React calls your component function for a particular render, it retrieves the current value (a snapshot) of the state
from that "shelf" and provides it as the context for this render to your component. Therefore, the state
value your component obtains in this render is fixed. Based on these values, it returns a brand new UI snapshot in JSX, which includes a fresh set of props
and event handlers, and these values are calculated and determined based on the specific state
value in this render! This mechanism ensures the isolation and consistency of each render, allowing components to reliably build and present UI based on their current state.
More about State: React State Execution Mechanism and Rendering Process Explained
"Current snapshot value" refers to the latest state value that React internally saves after the previous render is complete (i.e., the state managed and updated by useXXX
Hooks). Except for the first render, each time a component renders, the state returned by the Hook is that state snapshot.
In short: "current snapshot value" is simply the state value returned by the useXXX
Hook in the previous render. In simpler terms, it's the result of the last Hook execution.
In React, if an asynchronous operation is defined during rendering, the variables used within that asynchronous operation will be "fixed" to their values at the time of definition. Even if the component's state has updated when the asynchronous operation executes, it will still access the old values from when it was defined.
Setting a component's state adds a re-render to the queue. React batches state updates, meaning that multiple state updates within the same event loop will be merged into a single render.
React will process your state updates only after all code in the event handler has finished executing. This means that even if you call setNumber()
multiple times within the function, React will not immediately trigger multiple re-renders. Instead, it will wait until all setNumber()
calls are complete, merge these updates at once, and then perform a single render.
function Counter() {
const [number, setNumber] = useState(0);
function handleClick() {
setNumber(prev => prev + 1);
setNumber(prev => prev + 1);
setNumber(prev => prev + 1);
console.log("Click event handler finished");
}
console.log("Component rendering", number);
return <button onClick={handleClick}>Click me</button>;
}
In this example, although setNumber
is called three times in handleClick
, React will wait until all code in handleClick
has finished executing, then merge these three updates, ultimately triggering only one re-render, and number
will increase by 3. This avoids unnecessary multiple renders and improves performance.
Summary: React, through its batching mechanism, ensures that when state is updated multiple times within an event handler, the page is not repeatedly rendered. Instead, updates are efficiently applied in a single, unified render at the end.
function App() {
const [number, setNumber] = useState(0);
function handleClick() {
setNumber(number + 1);
console.log("number in event handler:", number); // Prints old value
}
console.log("number when component function body executes:", number); // Prints current render's state value (when this executes again, it means a re-render was triggered, and the state is the updated value)
return <button onClick={handleClick}>Click me</button>;
}
The console.log
inside the function body prints the "old state of the current render cycle."
Here, the console.log
inside the event function will always print the old value because the event function's closure captures a snapshot of the state at the time of the current render.
When the event function executes, the state update request has already been issued, but React hasn't re-rendered the component yet. When the re-render begins, the console.log
will print the value after the state has been updated.
The state accessed within the event function is the "old value," while the state accessed at the top level of the component function is the "latest value."
Regarding accessing old values inside functions:
This phenomenon is a classic example of the combined effect of React components and JavaScript's closure mechanism.
Detailed Explanation:
Closures "capture" variable snapshots at the time of definition.
- In JavaScript, when a function accesses external variables, it forms a closure that captures the state of those variables at the moment of the function's definition.
- The
number
variable inside the event handler is captured this way, locking in the state value from the current render. - So, even if the state changes later, the
number
inside the closure remains the old value.
React's rendering is snapshot-based.
- Each time React renders a component, the entire component function acts as a "snapshot."
- Within this snapshot, all functions and event handlers use the state values from this specific render.
- Even if event handlers execute later due to asynchronous calls or event triggers, they can only access the snapshot captured at their definition time.
Variable values in asynchronous operations are also old.
- For example, if you use
setTimeout
or a Promise to asynchronously execute code within an event handler, the state variables accessed by this asynchronous code will still be the old values from when they were defined. - This is a general rule of JavaScript closures and asynchronous execution, not specific to React.
- For example, if you use
function App() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setTimeout(() => {
console.log("count in async:", count); // Still prints old value because closure captured old value
}, 1000);
}
console.log("count during render:", count);
return <button onClick={handleClick}>Click me</button>;
}
Here, the count
accessed inside setTimeout
is the old value captured by the closure.
Summary: In React, the state accessed within event handlers and asynchronous callbacks is the old state from the "snapshot" at the time of their definition. This is determined by the combined mechanisms of JavaScript closures and React's rendering.
React waits until all code in the event handler has finished executing before actually processing your state updates.
This is precisely why, when you call setNumber()
multiple times in a single event, React still only triggers one re-render at the end.
You can imagine it like a waiter taking your order at a restaurant. The waiter won't immediately run to the kitchen after you order the first dish; instead, they'll patiently wait for you to finish ordering all your items, allowing you to modify previous choices, and even helping to record orders for others at your table.
In this scenario, React is like that elegant waiter—even if you call setState()
multiple times, it waits until you've completely "placed your order" before sending the final version to the "kitchen" for processing. This way, you can update multiple state variables at once, even state from different components, without triggering a series of unnecessary renders.
This behavior is called Batching. It not only makes React applications run more efficiently but also prevents awkward situations where only part of the state is updated, leading to a partially rendered UI.
However, note that React does not batch across multiple events that need to be triggered separately (e.g., multiple clicks). Each click is a separate "order." This ensures that if the first button click disables a form, the second click won't trigger a submission when it shouldn't.
If setState
(or other Hooks that trigger state updates, like useReducer
) is called during the rendering function, React does not immediately modify the state and re-render. Instead, it adds the state update task to an internal "update queue" and schedules a re-render task to execute after the current render is complete.
- If state is updated multiple times within the same render flow (whether the same or different state variables), React will merge them and calculate and apply them uniformly in the next render, rather than triggering multiple renders. This is Batching.
- The specific merging method depends on the form of the
setState
call:- Passing a value (
setCount(1)
): A later call will overwrite an earlier update to the same state. - Passing a function (
setCount(prev => prev + 1)
): React will apply each update function sequentially to the latest state, avoiding overwrites and ensuring correct calculation.
- Passing a value (
For multiple updates triggered by the same event (e.g., one click triggers multiple setState
calls), React will batch them, performing only one re-render at the end, thereby improving performance.
For state updates triggered by independent events (e.g., multiple button clicks by the user), React does not batch them. Instead, it processes each event's update separately and renders immediately. This ensures that each click takes effect immediately, for example, disabling a button right after the first click, preventing subsequent clicks from triggering operations when they shouldn't.
If you want to update the same state multiple times before the next render, you can pass a function that calculates the new value based on the current state, rather than directly passing a new value.
setNumber(n => n + 1); // Calculates based on current state
setNumber(number + 1); // Directly replaces with a specific value
Doing this tells React: "Please use the current state value to calculate the next state," rather than just "replace the state with this value."
In this example, n => n + 1
is an updater function. When you pass it to the state setter function, React handles it as follows:
- Event Execution Phase: React adds this updater function to an internal queue, rather than immediately calculating the result. It waits until all code in the event handler has finished executing before processing these queued updates.
- Next Render Phase: React iterates through this queue in order, passing the result of the previous update as an argument to the next updater function, eventually calculating the new state value.
For example, executing the following code:
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
React simply puts all three n => n + 1
functions into the queue during the event handler:
Update Queue | n | Return Value |
---|---|---|
n => n + 1 | 0 | 0 + 1 = 1 |
n => n + 1 | 1 | 1 + 1 = 2 |
n => n + 1 | 2 | 2 + 1 = 3 |
The initial number
is 0
. The first call yields 1
, the second call 2
, and the third call 3
. Ultimately, React saves 3
as the new state and returns this value during rendering.
Therefore, in this example, clicking the "+3" button once will indeed increase the value by 3, and intermediate calculation results will not be lost due to multiple updates overwriting each other.
What happens if you update state after replacing state?
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
Assuming number = 0
currently, this code in the event phase will tell React:
setNumber(number + 5)
: At this point,number
is0
, so this is a replacement operation, equivalent to "set state to5
." React adds "replace with 5" to the update queue.setNumber(n => n + 1)
: This is an updater function. React adds this function to the update queue.
In the next render phase, React processes the updates in the queue in order:
Update Type | n (Passed Value) | Return Value |
---|---|---|
Replace with 5 | 0 (unused) | 5 |
n => n + 1 | 5 | 6 |
The result is: first replace with 5, then add 1 to 5, ultimately yielding a state of 6
.
NotesetState(x)
can actually be seen as setState(() => x)
, but here the function parameter (n
) is not used, so directly passing a value will overwrite previous assignment updates to the same state.
What happens if you replace state after updating state?
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>
Assuming number = 0
currently, React will process this in the event handling phase as follows:
setNumber(number + 5)
- At the start of the event,
number = 0
→ calculates to5
- This is a replacement operation, React adds "replace with 5" to the update queue.
- At the start of the event,
setNumber(n => n + 1)
- This is an updater function, React adds it to the update queue.
setNumber(42)
- Directly replaces with
42
, React adds "replace with 42" to the update queue.
- Directly replaces with
In the next render phase, React will process the queue in order:
Update Type | n (Passed Value) | Return Value |
---|---|---|
Replace with 5 | 0 (unused) | 5 |
n => n + 1 | 5 | 6 |
Replace with 42 | 6 (unused) | 42 |
Final result: 42. The reason is simple—a subsequent "replace operation" directly overwrites the results of previous updates.
Summary: You can pass two types of values to setNumber
:
- Updater function (e.g.,
n => n + 1
)- Will be added to a queue and executed sequentially during the render phase.
- Will use the result of the previous state calculation.
- Must be a pure function, only returning a new value; do not modify state or produce side effects within it.
- Direct value (e.g.,
42
)- Will be added to the queue as a "replacement operation."
- Subsequent replacement operations will overwrite previous calculation results.
In Strict Mode, React will execute each updater function twice (the second result is discarded) to help you find issues caused by impure functions.
Event handling phase (or render phase): Only adds update requests to the queue; it does not immediately change state.
Next re-render phase: React sequentially retrieves updates from the queue, executes them in order (function updates depend on the previous result, value updates directly overwrite), finally calculates the new state, and uses it to render the UI.
So: the queue of state operations added in the previous step will be processed all at once during the next render.
Naming Suggestions:
When passing an updater function to setState
, you need to name the parameter (the old state value). A common practice is to use the first letter of the corresponding state variable, which is both concise and intuitive:
If you prefer higher code readability, you can choose the full variable name, or even add a prev
prefix, making it immediately clear to the reader that it refers to "the previous value":
In React, a functional update (where you pass a function to setState
) can indeed do many things—theoretically, you could call other functions, log messages, or even make requests inside it. However, React's design philosophy is that this function should be a pure function, only returning a new state based on the previous state (and necessary props) without producing any side effects.
If you find that your updater function needs to access external mutable data or perform side effects, it likely belongs in useEffect
, an event handler, or a custom Hook, rather than within setState
's functional update.
React's state can hold any type of JavaScript value, including objects. However, it's important to note: do not directly modify objects stored in state.
When you want to update an object, you should create a new object (or shallow copy the original object to create a duplicate), and then use this new object as the new state for the update (replacement).
Mutation refers to the act of directly modifying existing data, such as changing object properties or array elements.
Data Types and Immutability in React State:
You can store any type of JavaScript value in React state, including numbers, strings, booleans, and even objects and arrays.
- Numbers, strings, and booleans are primitive types, and they are immutable in JavaScript.
- This means the values themselves cannot be changed.
- For example, the number
0
itself cannot be changed into another number.
const [x, setX] = useState(0);
setX(5);
Here, although the value of x
changed from 0
to 5
, the number 0
itself was not modified; rather, it was directly replaced with a new value 5
.
React relies on the characteristic of immutable data to determine whether a component needs to re-render.
- For primitive types, directly replacing the value is fine because they are inherently immutable.
- However, for objects and arrays, if you directly modify their properties (i.e., cause mutation), React has difficulty detecting the change, which might lead to the UI not updating.
Therefore, it's crucial to avoid direct modification (mutation) of objects or arrays stored in state. You should use immutable update patterns, creating new objects or new arrays to trigger updates.
What does "immutable" mean?
"Immutable" means that the value of the data itself cannot be changed.
- For example, the number
5
is a value. You cannot "change" this number itself, like making the number 5 become 6. - You can only replace the original value with another number (like 6).
In other words, primitive type values like numbers, strings, and booleans are fixed and unchangeable; they can only be replaced.
let a = 5;
// You cannot modify the value of a to make it 6
// You can only use an assignment statement to replace a with 6
a = 6;
- Here, it's not "modifying" the value 5 itself, but rather making the variable
a
point to a new value6
.
Compared to Objects
Objects are "mutable" because you can directly modify their internal properties:
let obj = { name: 'Alice' };
obj.name = 'Bob'; // This is a modification, not a replacement
But numbers and strings cannot be modified this way—you can only replace them.
const [position, setPosition] = useState({ x: 0, y: 0 });
// Technically, you can indeed directly modify object properties, for example:
position.x = 5;
This is a mutation performed on the object itself.
However, even though objects stored in React state are mutable, you should still treat them as immutable, just like you would numbers, booleans, and strings.
That is, do not directly modify object properties; instead, create a new object and replace the existing state with it. Doing so ensures that React correctly detects state changes, thereby triggering the appropriate re-render.
Local mutation is acceptable.
Code like the following is problematic because it directly modifies an existing object stored in state:
position.x = e.clientX;
position.y = e.clientY;
However, the following syntax is perfectly fine because you are modifying a newly created object:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
In fact, it's equivalent to the more concise syntax:
setPosition({
x: e.clientX,
y: e.clientY
});
Mutation only causes issues when you directly modify an existing object that is already stored in state. Modifying a newly created object, however, does not produce side effects because this object is not yet referenced by other code, and changing it will not accidentally affect anything that depends on it.
This is known as local mutation. You can even perform local mutations during component rendering; such operations are both convenient and safe and will not cause any problems.
In the previous example, we always created a new position
object based on the current pointer position. However, in actual development, a more common scenario is to only update some fields within an object, while keeping other fields unchanged.
For instance, in a form, if we only want to update the value of one input field, we need to preserve the old values of other fields in the new object.
function handleFirstNameChange(e) {
person.firstName = e.target.value; // Directly mutates state
}
This modifies the state object from the previous render (causing mutation), which prevents React from detecting the state change and thus may not trigger a re-render.
The correct approach: Create a new object, copy the current values into it, and only overwrite the fields that need updating:
setPerson({
firstName: e.target.value,
lastName: person.lastName,
email: person.email
});
This ensures that a brand new object is generated each time, allowing React to recognize that the reference has changed, thereby triggering a render.
More concise syntax: Using the spread syntax (Shallow Copy)
setPerson({
...person, // Copies all fields from the original object (shallow copy)
firstName: e.target.value // Overwrites the field that needs modification
});
This is very convenient as it avoids manually copying each field.
Shallow copy means only copying the first level of properties:
- If the property is a primitive value (number, string, boolean, etc.), the value itself is directly copied.
- If the property is a reference type (object, array, function, etc.), its reference address is copied; a new object is not created.
Result: A shallow-copied object and the original object are independent at the first level, but reference type properties still point to the same memory location, so modifying the internal value of one will affect the other.
jsxconst obj1 = { name: 'Alice', address: { city: 'Paris' } }; const obj2 = { ...obj1 }; obj2.name = 'Bob'; // Does not affect obj1.name obj2.address.city = 'NY';// Will affect obj1.address.city
Difference between Deep Copy and Shallow Copy
Shallow Copy Only copies the values of the first-level properties; if it's a reference type, the reference is copied. Modifying the internal value of a reference type will affect the original object. Common methods:
Object.assign()
, spread syntax...
,Array.slice()
, etc.Deep Copy Recursively copies values at all levels; reference types also allocate new memory, making them completely independent. Modifying any property of the copied object will not affect the original object. Common methods:
structuredClone(obj)
(supported by modern browsers)
JSON.parse(JSON.stringify(obj))
(has limitations, such as losing functions,undefined
, circular references)Third-party libraries (
lodash.cloneDeep
)
Notes in React
For shallow object updates (e.g., updating form fields), the spread syntax is sufficient.
If your state contains nested objects and you need to update deep properties, you must shallow copy layer by layer:
setPerson({
...person,
address: {
...person.address,
city: 'NY'
}
});
This prevents accidental modification of internal references in the original object, maintaining React's immutability principle for state.
Tip:
In object literals, you can use square brackets [ ]
to wrap an expression whose evaluated result will be used as the property name:
const key = "age";
const person = {
[key]: 30
};
// Equivalent to { age: 30 }
In HTML <input>
elements, you can use the name
attribute to mark its corresponding state field, for example:
<input name="firstName" />
When an event triggers, e.target.name
will tell you the field name bound to this input, allowing you to update the state directly like this:
setPerson({
...person, // First shallow copy the original object
[e.target.name]: e.target.value // Use name as property key, dynamically update corresponding field
});
This avoids writing a separate handler for each field.
JavaScript Object "Nesting" Is Not True Nesting
Many beginners might think that the artwork
object below is "living inside" the obj
:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
Visually, this appears to be "nested objects."
But the truth is: JavaScript object properties do not store a copy of another object; instead, they store references. A reference is like a "pointer" that points to the location of an object in memory.
In reality, the code above is equivalent to:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1 // References obj1
};
Multiple Objects Sharing References
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};
// Modify obj3.artwork.city
obj3.artwork.city = 'Berlin';
console.log(obj1.city); // "Berlin"
console.log(obj2.artwork.city); // "Berlin"
Changing obj3.artwork.city
changes obj1.city
and obj2.artwork.city
as well.
Because they all point to the same object in memory.
obj1 ───▶ { title: 'Blue Nana', city: 'Hamburg', image: '...' }
▲
│
obj2.artwork ────┘
▲
│
obj3.artwork ────┘
Conclusion: Objects are connected by references, not physically contained like nested arrays.
When your state structure is complex and has multiple levels of nesting, it's often recommended to flatten it to simplify update operations. However, if you don't want to modify the data structure, you can also use Immer, a popular library, to manage nested state with a more concise, "direct modification"-like syntax.
For example, using Immer you can write:
npm install use-immer
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
This looks like directly modifying the object, but Immer will handle the immutable data copying and updating behind the scenes for you, without overwriting previous state, maintaining the correctness of React's state management.
How Immer Works
Immer works by creating a special draft
object (based on JavaScript's Proxy
), which records all your operations on it. It intelligently identifies which parts have been modified and then generates a brand new, updated object based on these modifications, eliminating the tediousness of manual deep copying.
Example:
import { useImmer } from 'use-immer';
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
function handleNameChange(e) {
updatePerson(draft => {
draft.name = e.target.value;
});
}
function handleTitleChange(e) {
updatePerson(draft => {
draft.artwork.title = e.target.value;
});
}
// ...
Flattening Definition
Flattening refers to the process of converting nested, hierarchical data structures into simpler, less layered structures. Simply put, it means breaking down multi-level nested objects or arrays into a "flat" form, reducing complexity when accessing and updating them.
Nested Structure (Unflattened)
const state = {
user: {
name: 'Alice',
address: {
city: 'Paris',
zip: '75001'
}
}
};
Accessing city
requires state.user.address.city
, and updating is also more cumbersome.
Flattened Structure
const state = {
userName: 'Alice',
userAddressCity: 'Paris',
userAddressZip: '75001'
};
Now, accessing and updating are much simpler, directly using state.userAddressCity
.
Why is direct state modification discouraged in React?
Directly modifying React's state leads to a series of issues, which is why we recommend always maintaining state immutability. The main reasons include:
- Clearer Debugging Not directly modifying state ensures that
console.log
output of old state is not affected by subsequent modifications, allowing you to accurately track state changes and rendering processes. - Performance Optimization React uses shallow comparison (
prevState === nextState
) to determine if a re-render is needed. If you directly modify the state object, the reference remains the same, causing React to mistakenly believe the state hasn't changed, skipping the update, and leading to UI desynchronization. - Support for New Features Many future React features rely on treating state as "snapshots." Directly modifying historical state breaks this mechanism, affecting the correct implementation of features.
- Easier Implementation of Complex Requirements Features like undo/redo, showing history, and state rollback all rely on preserving previous state copies. Directly modifying state makes these requirements exceptionally difficult to implement.
- Simple Implementation and Worry-Free Performance React itself does not rely on object proxying or interception mechanisms, so there's no need to worry about performance or complexity. You can freely store large objects without additional overhead.
Array Update Principles in React
Although arrays are mutable objects, they should be treated as immutable in React state.
Do NOT directly modify arrays in state (e.g., arr[0] = 'bird'
, push()
, pop()
).
DO update by generating a new array and passing it to setState
.
Common methods for generating new arrays:
Adding: concat()
, [...arr, newItem]
Deleting: filter()
, slice()
Replacing: map()
Sorting: [...arr].sort()
, [...arr].reverse()
Immer can be used to simplify operations, allowing for "mutable-like" syntax to produce immutable results.
Core Idea: When updating arrays, always replace the old array with a "new array" to avoid direct modification of the original state.
The slice
vs splice
Trap in React
slice()
: Returns a copy of an array (all or part), and does not modify the original array.splice()
: Directly modifies the original array (inserts or deletes elements).- In React state updates, you should prioritize using
slice()
(no 'p'!), to avoid the mutation issues caused bysplice()
.
use-immer
Practical Examples
import { useImmer } from "use-immer"; // Note the library import
Primitive Types:
const [count, updateCount] = useImmer(0);
// Increment by 1
updateCount(draft => {
draft += 1; // Appears mutable
});
Characteristic: For primitive values, draft
is the current value, not a reference.
Objects:
const [user, updateUser] = useImmer({ name: "Tom", age: 20 });
// Modify age
updateUser(draft => {
draft.age += 1;
});
Characteristic: Object properties can be modified directly, no copying needed.
Arrays:
const [list, updateList] = useImmer(["apple", "banana"]);
// Add element
updateList(draft => {
draft.push("orange"); // No need for concat
});
Characteristic: Methods like push
, splice
that modify the original array are fine, because Immer will generate a new copy.
Nested Objects:
const [state, updateState] = useImmer({
user1: { name: "Tom", address: { city: "NY" } },
user2: { name: "Jerry", address: { city: "LA" } },
user3: { name: "Spike", address: { city: "SF" } },
});
// Change city
updateState(draft => {
draft.user1.address.city = "LA";
});
Characteristic: Nested layers can be modified directly, no need for layer-by-layer spreading.
Nested Arrays:
const [matrix, updateMatrix] = useImmer([
[1, 2],
[3, 4]
]);
// Change second row, second column
updateMatrix(draft => {
draft[1][1] = 99;
});
Characteristic: Nested arrays can still be accessed and modified directly.
Arrays Nested with Objects:
const [todos, updateTodos] = useImmer([
{ id: 1, text: "Learn React", done: false },
{ id: 2, text: "Learn Immer", done: false }
]);
// Set done to true for id=2
updateTodos(draft => {
const todo = draft.find(t => t.id === 2);
todo.done = true;
});
// Can also be written as
updateTodos(draft => {
draft[1].done = true;
});
Method Differences
- Direct Index Access: Faster, more concise, but relies on fixed array order.
find
Lookup: Safer, more readable, correctly finds the target even if array order changes.
Characteristic: Found objects' properties can be modified directly; Immer will generate an immutable result for you.
Deep Nesting (Array + Object + Array):
const [data, updateData] = useImmer({
users: [
{
id: 1,
profile: {
name: "Tom",
tags: ["dev", "blogger"]
}
}
]
});
// Change the second tag of the first user
updateData(draft => {
draft.users[0].profile.tags[1] = "writer";
});
Characteristic: Regardless of how many layers deep, you can modify it directly like a regular JavaScript object.
Summary of Patterns
- In
use-immer
'supdateFn
,draft
is a proxy object that can be modified using native JS mutable syntax. - After modification, Immer will generate brand new immutable data to pass to the state.
- Suitable for deeply nested data updates, saving the hassle of layer-by-layer
...spread
operations.