In React, “reactivity” is not magic. It’s a very simple mental model:
Your UI is a projection of your state.
You don’t manually “refresh the page” or imperatively manipulate the DOM to reflect changes.
Instead, you update state, and React re-renders the parts of the UI that need to change.
This post breaks that down in a practical way, using a small (but real-world) example.
The core idea: data drives the interface
In traditional DOM-driven code, you often end up doing things like:
- selecting elements,
- adding/removing nodes,
- toggling classes,
- keeping UI and data in sync manually.
In React, you flip that:
- State is the source of truth
- Rendering describes what the UI should look like for a given state
- Events update state
- React handles the UI updates
Once this clicks, building UIs becomes much more predictable.
Why this feels great in real projects
This approach usually improves:
- Maintainability: fewer hidden side effects and “who changed the DOM?” bugs
- Readability: UI logic is expressed through state changes
- Consistency: the UI stays aligned with your data
- Scalability: you can grow features without turning the codebase into spaghetti
Practical example: search + filtered list + toggle
Let’s build a small “Tasks” UI that demonstrates the full loop:
- A
querystate for the search input - A
tasksstate as our data source - A derived list
visibleTasksbased onquery+tasks - A
toggle(id)action that updates the state (and the UI follows automatically)
The code
import { useMemo, useState } from "react";
export default function App() {
const [query, setQuery] = useState("");
const [tasks, setTasks] = useState([
{ id: 1, title: "Fix login bug", done: false },
{ id: 2, title: "Write API docs", done: true },
{ id: 3, title: "Refactor dashboard", done: false },
]);
// Derived data: computed from state (not stored as separate state)
const visibleTasks = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return tasks;
return tasks.filter((t) => t.title.toLowerCase().includes(q));
}, [tasks, query]);
const toggle = (id) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
};
return (
<div>
<h1>Tasks</h1>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search a task..."
/>
<ul>
{visibleTasks.map((t) => (
<li key={t.id}>
<label style={{ textDecoration: t.done ? "line-through" : "none" }}>
<input
type="checkbox"
checked={t.done}
onChange={() => toggle(t.id)}
/>
{t.title}
</label>
</li>
))}
</ul>
<p>
Showing <strong>{visibleTasks.length}</strong> / {tasks.length}
</p>
</div>
);
}
What’s happening here (step by step)
1) State is the source of truth
We store two pieces of state:
-
tasks: the actual task data -
query: what the user typed in the search input
const [query, setQuery] = useState("");
const [tasks, setTasks] = useState([...]);
Nothing else “owns” the UI. If the UI changes, it’s because one of these changed.
2) The UI describes a view of the state
This is the React mindset:
- for each task in
visibleTasks, render a<li> - checkbox state comes from
t.done - the label style depends on
t.done
{visibleTasks.map((t) => (
<li key={t.id}>
<label style={{ textDecoration: t.done ? "line-through" : "none" }}>
<input
type="checkbox"
checked={t.done}
onChange={() => toggle(t.id)}
/>
{t.title}
</label>
</li>
))}
You’re not “editing HTML”.
You’re declaring what should be displayed for the current state.
3) Updates are done via state setters (not direct mutation)
This part is crucial.
When the user toggles a checkbox, we call:
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
We are not mutating tasks in place.
We create a new array and a new object for the updated task.
That immutability is what makes updates predictable and easy to reason about.
4) Derived data: compute it, don’t store it
A common mistake is to store the filtered list as another state value.
Instead, compute it from the source state:
const visibleTasks = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return tasks;
return tasks.filter((t) => t.title.toLowerCase().includes(q));
}, [tasks, query]);
Why ?
- It reduces bugs (no duplicated sources of truth)
- It keeps your app consistent (one real state)
- It makes refactors easier
Note: For small lists you can skip
useMemoand just compute inline.
I’m using it here to highlight the “derived data” concept.
The mental model to remember
When you write React, think in this loop:
- State holds your data
- Render is a function of that data
- Events trigger state updates
- React updates the UI
That’s “reactivity” in React.
Where this scales (real apps)
This same pattern powers:
- dashboards (filters, sorting, pagination)
- forms (validation state, errors, submission states)
- complex UIs (tabs, modals, multi-step flows)
- data-heavy components (search, selection, bulk actions)
As your UI grows, you can move to:
-
useReducerfor more complex state transitions - context or state libraries when the state becomes global
- server-state tools (React Query, SWR) for async data
But the core idea remains the same: state → UI.
Top comments (0)