State is data a component remembers between renders and that changes over time. useState(initial) returns a pair, the current value and a setter; calling the setter stores the new value and schedules a re-render of that component.
const [count, setCount] = useState(0) is array destructuring, so the names are yours; the convention is x and setX.
The initial value is used only on the first render; later renders ignore the argument.
State is private to each instance: two <Counter /> elements keep separate counts.
React keeps state outside the function and matches it to hooks by call order, which is why hooks are called at the top level, never inside conditions or loops.
TypeScript infers the type from the initial value; pass it explicitly when the state starts as null: useState<User | null>(null).
ReactJunior
State & Events
useState, state as a snapshot, batching, updater functions, immutable updates of objects and arrays, lifting state up, events
Questions closed
0 / 20
- 0Review
- 9Asked often
Questions
A local variable is recreated on every render, and changing it tells React nothing, so no re-render happens and the next render starts from the initial value again. A state setter both keeps the value between renders and triggers a re-render.
No trigger: React re-renders a component when its state changes, its parent re-renders or a context it reads changes; count++ on a local variable is none of those.
No memory: the component function runs from scratch on each render, so let count = 0 is reset every time.
useRef keeps a value between renders without re-rendering; use it for values the screen does not show, such as timer ids and DOM nodes.
A module-level variable survives renders, but it is shared by every instance and leaks between tests and server requests.
You pass a function to a camelCase prop such as onClick or onChange, and React calls it with an event object when the event fires. To pass an argument, wrap the call in an arrow function: onClick={() => remove(item.id)}.
Pass the function, do not call it: onClick={handleClick} is right, onClick={handleClick()} runs the handler during render.
Handlers are usually declared inside the component, so they see the current props and state. Name them handleX, and the props that accept them onX.
The event is a SyntheticEvent with the same interface as the native one (e.target, e.preventDefault()); in TypeScript type it as React.MouseEvent<HTMLButtonElement> or React.ChangeEvent<HTMLInputElement>.
Event props work only on built-in elements; on your own component onSelect is just a prop that the component has to call.
Handlers are the place for side effects caused by a user action: a request on submit, analytics on a click.
The component calls setUpper during render: each call schedules another render, which calls it again, and React stops the loop with "Too many re-renders. React limits the number of renders to prevent an infinite loop."
Rendering must be pure: it computes JSX from props and state, without setters, requests or DOM writes.
Here the value is derived from a prop, so it should not be state at all: compute it during render.
The same error comes from onClick={setOpen(true)} in plain JavaScript: the setter runs during render instead of on click. Pass a function: onClick={() => setOpen(true)}.
React allows a setter during render in one guarded pattern only, adjusting state when a prop has changed (if (prev !== value)), and even that is usually better replaced with a key.
React decides whether to re-render by comparing the old and new state with Object.is, so a mutated object is still the same reference and the update can be skipped. New objects also keep old snapshots intact, which memoization, effect dependencies and concurrent rendering rely on.
Bail-out: setUser(user) after user.name = 'Ann' passes the same reference; React sees no change and does not re-render.
Memo and dependencies: React.memo, useMemo and effect dependencies compare by reference, so mutation makes them miss changes.
Snapshots: closures from earlier renders still hold the old object; mutating it rewrites the past, which breaks undo, transitions and debugging.
Shallow copies are enough: copy only the path you change ({ ...obj, field }); untouched nested objects are shared, which is cheap.
Mutating a fresh copy you just created is fine, and Immer (useImmer, Redux Toolkit) lets you write mutating syntax that produces a new object.
Create a new object with spread syntax and the changed field, and pass it to the setter. For a nested field, copy every level on the path to it: the outer object and the inner one.
setForm({ ...form, email }) copies the top-level fields and overrides one of them.
Nested: { ...person, address: { ...person.address, city } }. Spreading only the outer level keeps the old inner object, and mutating that object is the bug.
A computed key lets one handler update any field by its name: { ...form, [e.target.name]: e.target.value }.
Deep nesting is a sign to flatten the state, or to use Immer (produce, useImmer) so the update reads like a mutation.
structuredClone copies everything, which is wasteful and breaks reference equality for the parts that did not change.
The setter of useState replaces the state with exactly what you pass; it does not merge. setFilters({ query }) stores an object that has only query, so category becomes undefined and the select falls back to the empty "All" option.
A habit from classes: this.setState({ query }) merged the fields into the existing state, so code ported from a class component often carries this bug.
Fix: copy the previous object, setFilters((prev) => ({ ...prev, query })); the updater form also keeps two quick updates from overwriting each other.
Why TypeScript did not catch it: every field of Filters is optional, so { query } is a valid Filters. With required fields the call fails to compile: "Property 'category' is missing".
Or split the state: unrelated values such as query and category can live in two useState calls, and then there is nothing to merge.
Use methods that return a new array: spread to add, filter to remove, map to update, and pass the result to the setter. Do not call push, splice, sort or assign by index on the state array itself, because they mutate it.
Add: [...items, item] at the end, [item, ...items] at the start, items.toSpliced(i, 0, item) in the middle.
Remove: items.filter((t) => t.id !== id).
Update one item: items.map((t) => (t.id === id ? { ...t, done: !t.done } : t)); copy the changed item too, not only the array.
Sort or reverse: toSorted() and toReversed() (ES2023) return copies; or copy first with [...items].sort().
Use the updater form, setItems((prev) => ...), when several updates can happen before the next render.
Lifting state up means moving state from two components into their closest common parent and passing it down as props, together with callbacks that change it. You do it when two components must always show or change the same data.
The parent becomes the single source of truth, and the children become controlled: they get a value and an onChange-style callback.
Lift only as high as the closest common parent; lifting higher re-renders more of the tree for nothing.
When data has to pass through many layers that do not use it (prop drilling), use composition with children or context instead of lifting further.
The opposite move is valid too: state that only one child uses belongs in that child.
Each render sees a fixed value of state for its whole lifetime: calling the setter does not change the variable you already have, it asks React to render again with the new value. Code after setCount(count + 1) in the same handler still reads the old count.
count is a plain const captured by the closures of that render: handlers, timeouts and effects.
A setTimeout or the code after an await started in an old render reads that render's state, even if the screen has moved on. Use the updater form to compute from the latest value, or a ref to read the latest value later.
To use the new value right away, compute it into a local variable first: const next = count + 1; setCount(next); save(next);.
This is what makes React predictable: a handler never sees a half-updated state, and renders do not interfere with each other.
Instead of a value you pass a function, setCount((c) => c + 1), and React calls it with the latest pending state while it processes the update queue. Use it when the next state depends on the previous one and several updates can happen before a re-render.
setCount(count + 1) three times in one handler adds 1, because each call reads the same snapshot; setCount((c) => c + 1) three times adds 3.
It also fixes stale closures: a setInterval or an effect can update state without listing that state as a dependency.
Updaters must be pure: in StrictMode React calls them twice in development to catch side effects.
Name the argument after the state (c, prevItems) so it is clear what it holds.
It is not needed everywhere: setName(e.target.value) replaces the value and does not depend on the previous one.
The alert shows 0 and the button then shows 1. Setters do not change number in the running handler; they queue updates that React applies in order on the next render: replace with 5, then 5 + 1 = 6, then replace with 0 + 1 = 1.
In the queue a value means "replace with this", and a function means "compute from the result so far".
number is 0 in all three calls because it is this render's snapshot, which is why the last call wipes out the first two.
alert(number) runs before any re-render, so it shows the old value.
All three updates are batched into one render. To end up with 6, drop the last call.
Batching means React groups several state updates into one re-render instead of rendering after every setter call. Since React 18 (createRoot) it batches everywhere: in promises, setTimeout and native event listeners, not only in React event handlers.
Before React 18, updates inside setTimeout or after await rendered once per setter call.
Updates are applied after the handler finishes, so the DOM never changes in the middle of it.
React does not batch across separate user events: two clicks are two renders, and the second click sees the result of the first.
flushSync(() => setX(...)) from react-dom forces a synchronous render, for example to scroll to an item you just added; it costs performance, so use it rarely.
Batching is also why reading state right after the setter still gives the old value.
push mutates the existing array and setTags(tags) passes the same reference, so the Object.is check sees no change and React skips the re-render.
The data did change in memory, which is why the next render (caused by typing) shows the tag; until then the screen is out of sync.
Fix: build a new array, setTags((prev) => [...prev, text]), so the reference changes.
The same bug comes with sort(), reverse(), splice(), and with obj.field = x followed by setObj(obj).
Mutation also breaks everything that compares by reference: React.memo children, useMemo and effect dependencies.
React compares the new and the current value with Object.is, and if they are equal it skips re-rendering the component and its children. React may still call the component function once before bailing out, but it does not commit anything.
For primitives this is plain equality: setCount(5) when count is already 5 does nothing.
For objects and arrays only the reference counts: a new object with identical content (setUser({ ...user })) always re-renders, and a mutated old one does not.
Object.is differs from === in two cases: NaN equals NaN, and +0 does not equal -0.
So calling setOpen(false) repeatedly is safe and needs no if (open) guard.
If you pass a function, useState(() => compute()), React calls it only on the first render. With useState(compute()) the function runs on every render and its result is thrown away after the first one, which matters when it is expensive, like parsing localStorage.
Pass the function itself, useState(createInitialTodos), not its result useState(createInitialTodos()).
The initializer must be pure and take no arguments; StrictMode calls it twice in development.
The initial value is read once: if it comes from a prop, later changes of that prop do not reset the state.
useReducer(reducer, arg, init) has the same lazy form through its third argument.
Keep the minimal state that cannot be computed: anything derivable from props or other state is calculated during render, not stored. Avoid duplicated data, contradictory flags and copies of props, because every extra copy has to be kept in sync by hand.
Derive, do not store: fullName from firstName and lastName, visibleTodos from todos and filter. Add useMemo only if the computation is measurably slow.
Store ids, not copies: selectedId instead of selectedItem, so editing the item in the list does not leave a stale copy.
One status instead of several booleans: status: 'idle' | 'sending' | 'sent' instead of isSending and isSent, which can both end up true.
Do not mirror props: useState(props.color) reads the prop once and ignores later updates; read the prop directly, or call it initialColor when ignoring updates is intended.
Group what changes together: one { x, y } object for a position, separate state for unrelated values.
React wraps native events in a SyntheticEvent, attaches one listener per event type to the root container and dispatches the event through your component tree. e.preventDefault() cancels the browser's default action; e.stopPropagation() stops the event from reaching parent handlers.
Events bubble up the React tree: a click on a button inside a div with onClick triggers both handlers, the child's first.
Adding Capture to the name (onClickCapture) runs the handler on the way down, before the children; it is useful for logging or blocking clicks.
Since React 17 the listeners sit on the root element, not on document, so several React apps on one page and your own document listeners behave predictably.
Events from a portal bubble to its parent in the React tree, not the DOM tree: a click inside a modal reaches the component that rendered the modal.
Event pooling is gone since React 17, so reading e after an await is safe; e.nativeEvent gives the browser event.
Do not put every event into state. Keep the raw value in a ref or write it straight to the DOM, and set state only when something React renders has to change: a derived value such as isScrolled, or at most once per animation frame.
Why it hurts: these events fire dozens of times a second, and a setter on each one re-renders the component and its children, so scrolling and dragging stutter. A value that must render belongs in the small component that shows it.
Store a coarse value: setScrolled(window.scrollY > 0) re-renders only when the boolean flips, because a setter called with the current value bails out.
One update per frame: schedule a single requestAnimationFrame, read the latest value inside it, and cancel it in the cleanup.
Bypass React for pure visuals: progress bars, parallax and drag handles set transform or a CSS variable through a ref.
Observers instead of listeners: IntersectionObserver for visibility and infinite scroll, ResizeObserver for an element's size; position: sticky needs no JavaScript at all.
Passive listeners: { passive: true } on touchmove or wheel promises not to call preventDefault, so scrolling never waits for the handler. React attaches onTouchStart, onTouchMove and onWheel as passive already, so to block scrolling add a native listener with { passive: false } in an effect.
Keep a history instead of a single value: past, present and future in one useReducer. A change pushes present onto past and clears future; undo moves present to the front of future and takes the last item of past; redo does the reverse.
The model
One reducer updates the three parts together, so they never get out of sync, and dispatch stays stable for children and effects.
Immutable updates make it cheap: each snapshot shares every unchanged object with the previous one, so the history holds references, not deep copies. A mutation would silently rewrite the past.
**A new change after an undo clears future: the undone branch is gone, as in every editor.
canUndo and canRedo are derived from the array lengths, not stored.
What interviewers ask next
Memory**: cap past, for example at 100 entries.
Typing: an entry per keystroke makes undo useless; update present without a history entry while the user types, and commit after a pause or on blur.
Shortcuts: handle Ctrl+Z and Ctrl+Shift+Z (Cmd on macOS) in a keydown listener, and skip text fields, which have their own native undo.
What stays out: selection, scroll position and loading flags; only the document belongs in the history.
When snapshots stop scaling
For large documents store changes instead of copies: Immer's produceWithPatches (after enablePatches()) returns patches and inverse patches, and undo applies the inverse ones.
Undoing a saved change means sending a compensating request, and collaborative editors keep a per-user undo stack, such as Yjs's UndoManager. redux-undo and Zustand's zundo middleware use the same past, present and future model.