React 16 ⚛️ Updating Primitive State Values
State in React is the data a component owns and can change over time. The primitive values — strings, numbers, booleans — are the simplest form of state, and they are where most React state work begins. Updating a primitive is straightforward: call the setter returned by useState with the new value. But the details matter. The setter is asynchronous, the update is batched, and when the new value depends on the old one, the functional form of the setter is required. Getting these details right is the difference between a counter that increments once and one that increments twice.
The primitives themselves are immutable in JavaScript. A string is not modified in place; a new string is produced and the variable is reassigned. This maps naturally onto React’s model: state is not mutated, it is replaced. The setter receives the new value, and React schedules a re-render with that value. Understanding this replacement model is the foundation for understanding why arrays and objects need to be treated differently from primitives when they are updated.
This chapter covers declaring primitive state, calling the setter, reading the current value, functional updates, batching, common patterns for strings, numbers, and booleans, and the mistakes that produce stale values or skipped updates.
Key point: useState returns the current value and a setter. Call the setter with the new value to update state and trigger a re-render. State updates are asynchronous and batched. When the new value depends on the previous value, use the functional form setCount(c => c + 1). Reading the state variable immediately after calling the setter gives the old value.
Why primitive state matters
The starting point. Every React application uses primitive state. A counter, a toggle, a form field, a loading flag — these are the building blocks, and they are all primitives. Understanding how to update them correctly is the prerequisite for everything else.
The asynchronous problem. React state updates are not immediate. The setter schedules the update, and the new value is available in the next render. Reading the variable after calling the setter gives the value from the current render, not the new one. This is the most common source of confusion.
The batching problem. React batches multiple state updates into a single re-render. In React 18 and later, batching applies to updates in event handlers, promises, timeouts, and native event handlers. The batching is what makes the functional update necessary when the new value depends on the old one.
The functional update problem. When the update depends on the previous value, the direct form uses the value from the render in which the setter was created. If the setter is called multiple times in the same handler, each call uses the same stale value. The functional form receives the latest value from React and is correct in all cases.
The replacement problem. Primitives are replaced, not mutated. A number cannot be modified in place; the setter replaces it. This is the model that extends to arrays and objects, where mutation must be avoided and new values must be created.
a. Declaring primitive state
The useState hook declares a piece of state and returns the value and the setter.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return <p>Count: {count}</p>;
}
The initial value is passed to useState. For a number, it is 0. For a string, it is ''. For a boolean, it is false.
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [isOpen, setIsOpen] = useState(false); // boolean
The names are conventions. The setter is conventionally named set followed by the capitalized state name: setCount, setName, setIsOpen. The convention makes the code readable.
A component can have multiple primitive states:
function Form() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
<button disabled={isSubmitting}>Submit</button>
</form>
);
}
Each state variable has its own setter, and updating one does not affect the others.
b. Calling the setter
The setter is called with the new value.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(5)}>
Set to 5
</button>
);
}
The setCount(5) call schedules a re-render with count equal to 5. The component re-renders, and the new value is available.
The setter can be called with any expression that produces the new value:
setCount(count + 1); // based on the current value
setCount(0); // reset
setCount(Math.max(count, 10)); // computed
For a boolean, the setter is often called with the negation of the current value:
setIsOpen(!isOpen); // toggle
setIsOpen((open) => !open); // functional toggle
The functional form is preferred for toggles because it always uses the latest value.
c. Reading the current value
The state variable holds the value from the current render. Reading it after calling the setter gives the old value, because the update is asynchronous.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // still the old value
};
return <button onClick={handleClick}>{count}</button>;
}
The console.log(count) prints the value from the render in which the handler was created, not the value that will be set. The new value appears only in the next render.
To read the new value, compute it before calling the setter:
const handleClick = () => {
const next = count + 1;
setCount(next);
console.log(next); // the new value
};
Or use the functional form, which receives the current value as an argument:
setCount((prev) => {
const next = prev + 1;
console.log(next); // the new value
return next;
});
The functional form is the standard way to read the current value inside the update.
d. Functional updates
When the new value depends on the previous value, the setter should be called with a function rather than a value. The function receives the previous state and returns the new state.
setCount((prev) => prev + 1);
The functional form is necessary when the setter is called multiple times in the same handler, because the direct form uses the same stale value for each call.
// Direct form: count increases by 1
const handleClick = () => {
setCount(count + 1);
setCount(count + 1); // both use the same count
};
// Functional form: count increases by 2
const handleClick = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
};
React batches the two updates, but the functional form ensures that each receives the result of the previous one. The direct form captures count from the current render, which is the same for both calls.
The functional form is also the correct choice when the setter is called from an asynchronous callback:
setTimeout(() => {
setCount((c) => c + 1); // always current
}, 1000);
In a timeout, the closure captures the count from the render in which the timeout was created. If the state has changed since then, the direct form uses the stale value. The functional form uses the latest value.
e. Batching
React batches multiple state updates into a single re-render. When several setters are called in the same event handler, React performs one re-render rather than one per setter.
const handleClick = () => {
setName('Alice');
setAge(30);
setIsSubmitting(true);
// One re-render, not three
};
Batching is automatic in React 18 and later, including in promises, timeouts, and native event handlers. In React 17 and earlier, batching applied only to updates inside React event handlers.
The batching means that a handler can update multiple pieces of state, and the component re-renders once with all the new values. This improves performance and ensures that intermediate states are not rendered.
A consequence of batching is that the individual setters do not see each other’s updates. Each setter receives the state from the current render, unless the functional form is used.
const handleClick = () => {
setCount(count + 1); // uses current count
setCount(count + 1); // uses the same current count
// Result: count increases by 1, not 2
};
const handleClick = () => {
setCount((c) => c + 1); // uses the latest count
setCount((c) => c + 1); // uses the latest count
// Result: count increases by 2
};
The functional form is what makes multiple updates to the same state work correctly.
f. Common patterns
Increment and decrement.
setCount((c) => c + 1);
setCount((c) => c - 1);
Reset.
setCount(0);
Toggle.
setIsOpen((open) => !open);
Controlled input.
<input value={name} onChange={(e) => setName(e.target.value)} />
Number input.
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
Select.
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
Checkbox.
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
/>
Each pattern uses the setter with the new value. The controlled input patterns read the value from the event and pass it to the setter.
Complete Example Session
// ============================================
// PART 1: DECLARE PRIMITIVE STATE
// ============================================
function Counter() {
const [count, setCount] = useState(0);
return <p>Count: {count}</p>;
}
// ============================================
// PART 2: CALL THE SETTER
// ============================================
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(5)}>Set to 5</button>
);
}
// ============================================
// PART 3: STATE IS ASYNCHRONOUS
// ============================================
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // still the old value
};
return <button onClick={handleClick}>{count}</button>;
}
// ============================================
// PART 4: FUNCTIONAL UPDATE
// ============================================
function Counter() {
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
};
return (
<div>
<p>{count}</p>
<button onClick={incrementTwice}>+2</button>
</div>
);
}
// ============================================
// PART 5: TOGGLE BOOLEAN
// ============================================
function Toggle() {
const [isOpen, setIsOpen] = useState(false);
return (
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? 'Close' : 'Open'}
</button>
);
}
// ============================================
// PART 6: CONTROLLED TEXT INPUT
// ============================================
function NameInput() {
const [name, setName] = useState('');
return (
<input value={name} onChange={(e) => setName(e.target.value)} />
);
}
// ============================================
// PART 7: CONTROLLED NUMBER INPUT
// ============================================
function AgeInput() {
const [age, setAge] = useState(0);
return (
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
);
}
// ============================================
// PART 8: CONTROLLED CHECKBOX
// ============================================
function Checkbox() {
const [isChecked, setIsChecked] = useState(false);
return (
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
/>
);
}
// ============================================
// PART 9: BATCHING
// ============================================
function Form() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const reset = () => {
setName('');
setAge(0);
// One re-render with both values updated
};
return <button onClick={reset}>Reset</button>;
}
// ============================================
// PART 10: FUNCTIONAL UPDATE IN ASYNC CALLBACK
// ============================================
function DelayedCounter() {
const [count, setCount] = useState(0);
const incrementLater = () => {
setTimeout(() => {
setCount((c) => c + 1); // always current
}, 1000);
};
return <button onClick={incrementLater}>{count}</button>;
}
These ten parts cover declaring primitive state, calling the setter, the asynchronous nature of state, functional updates, toggling a boolean, controlled text input, controlled number input, controlled checkbox, batching, and functional updates in async callbacks.
Quick Reference
Primitive State Types
| Type | Initial value | Example |
|---|---|---|
| Number | 0 | useState(0) |
| String | '' | useState('') |
| Boolean | false | useState(false) |
Setter Forms
| Form | Example | Use case |
|---|---|---|
| Direct | setCount(5) | New value known |
| Functional | setCount((c) => c + 1) | Depends on previous |
| Reset | setCount(0) | Reset to initial |
| Toggle | setIsOpen((o) => !o) | Boolean toggle |
Common Patterns
| Pattern | Code |
|---|---|
| Increment | setCount((c) => c + 1) |
| Decrement | setCount((c) => c - 1) |
| Reset | setCount(0) |
| Toggle | setIsOpen((o) => !o) |
| Text input | setName(e.target.value) |
| Number input | setAge(Number(e.target.value)) |
| Checkbox | setChecked(e.target.checked) |
Batching
| Aspect | React 17 | React 18+ |
|---|---|---|
| Event handlers | Batched | Batched |
| Promises | Not batched | Batched |
| Timeouts | Not batched | Batched |
| Native handlers | Not batched | Batched |
Functional vs Direct
| Situation | Recommendation |
|---|---|
| New value depends on old | Functional |
| Multiple updates in one handler | Functional |
| Update in async callback | Functional |
| New value is constant | Direct |
Best Practices
✅ Do This:
// Use functional updates when depending on the previous value
setCount((c) => c + 1);
// Use functional form for toggles
setIsOpen((o) => !o);
// Read the value from the event in controlled inputs
setName(e.target.value);
// Use Number() for number inputs
setAge(Number(e.target.value));
// Use the functional form in async callbacks
setTimeout(() => setCount((c) => c + 1), 1000);
❌ Don’t Do This:
// Rely on the state value immediately after setting
setCount(count + 1);
console.log(count); // ❌ old value
// Use the direct form multiple times
setCount(count + 1);
setCount(count + 1); // ❌ increments by 1, not 2
// Mutate the state variable
count = 5; // ❌ no effect, state is read-only
// Forget to convert the input value
setAge(e.target.value); // ❌ string, not number
// Use the direct form in async callbacks
setTimeout(() => setCount(count + 1), 1000); // ❌ may use stale value
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| State reads as old value | Update is asynchronous | Read in the next render |
| Multiple updates collapse | Used the direct form | Use the functional form |
| Toggle doesn’t work | Used the direct form | Use setX((v) => !v) |
| Number becomes string | e.target.value is always a string | Wrap in Number() |
Checkbox uses value | Checkbox uses checked | Use e.target.checked |
| State changes ignored | Mutated the variable | Call the setter |
| Stale value in async | Closure captured old value | Use the functional form |
Real-World Examples
1. Counter
const [count, setCount] = useState(0);
<button onClick={() => setCount((c) => c + 1)}>+</button>
2. Toggle
const [isOpen, setIsOpen] = useState(false);
<button onClick={() => setIsOpen((o) => !o)}>Toggle</button>
3. Text Input
const [name, setName] = useState('');
<input value={name} onChange={(e) => setName(e.target.value)} />
4. Number Input
const [age, setAge] = useState(0);
<input type="number" value={age}
onChange={(e) => setAge(Number(e.target.value))} />
5. Checkbox
const [checked, setChecked] = useState(false);
<input type="checkbox" checked={checked}
onChange={(e) => setChecked(e.target.checked)} />
6. Select
const [status, setStatus] = useState('active');
<select value={status} onChange={(e) => setStatus(e.target.value)}>...</select>
7. Loading Flag
const [isLoading, setIsLoading] = useState(false);
8. Reset
const reset = () => { setCount(0); setName(''); };
9. Increment by N
setCount((c) => c + 5);
10. Async Increment
setTimeout(() => setCount((c) => c + 1), 1000);
Visual
State Update Flow
┌──────────────────────────────────────────────────────────────┐
│ 1. Component renders with count = 0 │
│ │ │
│ 2. User clicks button │
│ │ │
│ 3. Handler calls setCount(1) │
│ │ │
│ 4. React schedules a re-render │
│ │ │
│ 5. Handler completes; console.log(count) still shows 0 │
│ │ │
│ 6. React re-renders with count = 1 │
│ │ │
│ 7. UI updates │
└──────────────────────────────────────────────────────────────┘
Direct vs Functional Update
┌──────────────────────────────────────────────────────────────┐
│ DIRECT: │
│ setCount(count + 1); │
│ setCount(count + 1); │
│ └── Both use count from this render │
│ └── Result: count increases by 1 │
│ │
│ FUNCTIONAL: │
│ setCount((c) => c + 1); │
│ setCount((c) => c + 1); │
│ └── Each receives the latest value │
│ └── Result: count increases by 2 │
└──────────────────────────────────────────────────────────────┘
Batching
┌──────────────────────────────────────────────────────────────┐
│ const handleClick = () => { │
│ setName('Alice'); │
│ setAge(30); │
│ setIsSubmitting(true); │
│ }; │
│ │
│ Three setters called, but one re-render. │
│ The component re-renders with all three new values. │
└──────────────────────────────────────────────────────────────┘
Controlled Input
┌──────────────────────────────────────────────────────────────┐
│ State: name = '' │
│ │
│ <input value={name} onChange={(e) => setName(e.target.value)} />│
│ │
│ 1. User types 'A' │
│ 2. onChange fires with e.target.value = 'A' │
│ 3. setName('A') │
│ 4. Re-render with name = 'A' │
│ 5. Input displays 'A' │
│ │
│ The state is the source of truth; the input reflects it. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Declaration | const [value, setValue] = useState(initial) |
| Setter | setValue(newValue) |
| Functional setter | setValue((prev) => newValue) |
| Asynchronous | Update is not immediate |
| Batching | Multiple setters, one re-render |
| Toggle | setIsOpen((o) => !o) |
| Text input | setName(e.target.value) |
| Number input | setAge(Number(e.target.value)) |
| Checkbox | setChecked(e.target.checked) |
| Async update | Use the functional form |
Key takeaways:
useStatereturns the current value and a setter. The setter schedules an update and a re-render. The initial value is used only on the first render.- State updates are asynchronous. Reading the state variable immediately after calling the setter gives the old value. The new value is available in the next render.
- Use the functional form when the new value depends on the old one.
setCount(c => c + 1)receives the latest value. The direct form uses the value from the render in which the setter was created. - Use the functional form for multiple updates in one handler. The direct form uses the same stale value for each call. The functional form receives the result of the previous update.
- React batches multiple updates into one re-render. In React 18 and later, batching applies to promises, timeouts, and native event handlers as well as React event handlers.
- The controlled input patterns read the value from the event. Text uses
e.target.value, number usesNumber(e.target.value), and checkbox usese.target.checked. - Use the functional form in asynchronous callbacks. A closure in a timeout or promise captures the state from the render in which it was created. The functional form always uses the latest value.
Remember: Updating primitive state is the foundation of React interactivity. The setter replaces the value, and React re-renders with the new value. The two details that matter most are the asynchronous nature of the update and the functional form of the setter. Reading state immediately after setting gives the old value. Using the direct form multiple times uses the same stale value. The functional form solves both problems by receiving the latest value from React. The controlled input patterns are the same idea applied to form fields: the state is the source of truth, and the setter updates it from the event. Master these patterns, and the rest of React state management becomes a matter of extending them to arrays and objects.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!