Module 3: State and Interactivity
Conditional Rendering and How React Preserves State
The practical ways to show or hide UI, the pitfalls of && and ternaries, and the position-based rule that decides whether state survives.
Almost every screen is conditional. Logged in or out, loading or loaded, empty list or full one. React has no special v-if style directive for this. You use plain JavaScript, because JSX is just JavaScript.
Early return#
When a component has completely different output in some situations, return early. It's the clearest option and there's almost no nesting.
function UserPanel({ user, isLoading, error }) {
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <p>No user found.</p>;
return (
<section>
<h2>{user.name}</h2>
<p>{user.email}</p>
</section>
);
}
Read that top to bottom and you get a list of the states the component can be in. The "happy path" comes last, with all the edge cases filtered out above it.
The one limit: do this after all your hook calls. Hooks can't sit after a conditional return, because that would change the hook order between renders.
Ternary#
For an either-or inside JSX:
<button>{isLoggedIn ? "Log out" : "Log in"}</button>
{isLoggedIn ? <Dashboard /> : <LoginForm />}
Nested ternaries get unreadable fast. Once you're past one level, move the logic into a variable or a small function above the return.
Logical AND#
When you want something or nothing:
{hasUnread && <Badge count={unreadCount} />}
This works because false, null and undefined render nothing, and && returns its left side when that's falsy. That's also where the trap is, since 0 is falsy but renders.
{messages.length && <MessageList />} // shows "0" when empty
{messages.length > 0 && <MessageList />} // fine
{Boolean(messages.length) && <MessageList />} // also fine
Whenever the left side could be a number, make it a real boolean. NaN has the same issue.
Storing JSX in variables#
function Status({ state }) {
let content;
if (state === "ok") content = <span className="ok">All good</span>;
else if (state === "warn") content = <span className="warn">Check this</span>;
else content = <span className="bad">Down</span>;
return <div className="status">{content}</div>;
}
For many branches keyed by a value, a lookup object is tidier than a chain of ifs:
const icons = { ok: <CheckIcon />, warn: <WarnIcon />, bad: <CrossIcon /> };
function Status({ state }) {
return <div>{icons[state] ?? <UnknownIcon />}</div>;
}
Hiding vs removing#
There are two different ways to make something disappear, and they behave differently.
// Removed from the tree: unmounted, state is lost
{isOpen && <Panel />}
// Still mounted, hidden by CSS: state is kept
<Panel style={{ display: isOpen ? "block" : "none" }} />
If Panel contains a half-typed form, unmounting throws that away. Sometimes that's what you want, and sometimes it's a bug report. React 19.2 also added an Activity component that can hide parts of the UI while preserving their state, which is worth knowing about once you hit this problem.
How React decides whether to keep state#
This is the idea most tutorials skip, and it's the key to a whole class of weird bugs.
State is not attached to the component function. It's attached to the component's position in the tree. On every render React compares the new tree to the old one, position by position. If the same type of component is at the same position, React treats it as the same instance and keeps its state. If the type changes, or it's gone, the state is destroyed.
function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? <Counter fancy /> : <Counter />}
<label>
<input type="checkbox" checked={isFancy} onChange={(e) => setIsFancy(e.target.checked)} />
Fancy
</label>
</div>
);
}
Both branches put a Counter in the exact same spot. So toggling the checkbox does not reset the count. React sees "Counter at position 0 before, Counter at position 0 now," so it updates props and keeps state.
before: after:
div div
└─ Counter └─ Counter ← same type, same spot → state kept
(count: 5) (count: 5)
before: after:
div div
└─ Counter └─ p ← different type → Counter unmounted, state gone
Now the surprising direction. If you render two different-looking things that happen to be the same component at the same position, state leaks from one to the other:
{isPlayerA ? <Scoreboard player="A" /> : <Scoreboard player="B" />}
// switching players keeps the old score because it's the same Scoreboard at the same spot
The fix is to tell React they're distinct things with a key:
{isPlayerA ? <Scoreboard key="A" player="A" /> : <Scoreboard key="B" player="B" />}
Different key, different identity, state resets.
Where people get burned#
- The
0 &&bug, over and over. - Nested ternaries three levels deep that nobody can read.
- An early return placed before a
useStateoruseEffectcall, which triggers a hooks-order error. - Surprise state leaks when swapping two instances of the same component.
- Unmounting a form to hide it and losing what the user typed.
- Setting
display: noneon something heavy that still runs its effects the whole time.
Practice#
- Build a
Spinner/ error / data / empty state for a fake request using early returns. Drive it with astatusstring in state. - Make a tabbed panel in two versions. In the first, only the active tab is rendered. In the second, all tabs stay mounted and non-active ones are hidden with CSS. Type something into an input on tab one and compare what happens when you switch tabs.
- Reproduce the
Scoreboardstate-leak bug, then fix it with akey. - Write a
Badgethat shows a count only when it's above zero, and test it with0,1, andundefined.