Skip to main content
JOHN ZDANIS

Explicit by Default

A single instinct — don't make the reader reconstruct what you could have just stated — and how it shows up everywhere from hook design to control flow.


I spent a long time trying to teach Claude Code the rules I'd collected over eight years of writing software for a living. Stating the rules directly didn't stick. What worked, eventually, was walking through an actual refactor out loud and letting it ask whatever it needed to understand the reasoning behind each choice. Afterward, it told me the why mattered more to it than the rule itself — so I started leaning into that, explaining the reasoning behind everything instead of just handing over the conclusion. After a few of these sessions, it noticed something I'd never said outright: most of my individual rules were instances of one thing. It named that thing Explicit by Default — don't make the reader reconstruct something you could have just stated. The default is the thing you reach for first, before you've earned the right to take a shortcut. And it gave the cost of skipping it a better name than I'd ever managed: the Reader Tax. Because someone pays for every shortcut, and that someone is usually whatever dummy has to read the code next. Most likely me. Here are the rules that prompted it.

Named hooks, and when to split them

I came across Kyle Shevlin's Use Encapsulation back when I still wrote code for a living. It ships as an eslint rule, prefer-custom-hooks, and I've kept using it ever since — these days mostly on my own projects. All it checks is one thing: is a built-in hook (useState, useEffect, useRef, whatever) being called directly inside a component, or has it been moved into a custom hook of your own? Here's a dropdown component before the rule had anything to say about it:

export function HistoryDropdown() {
	const [isOpen, setIsOpen] = useState(false);
	const [entries, setEntries] = useState<HistoryEntry[]>(() => loadHistory());
	const [confirmingIdx, setConfirmingIdx] = useState<number | null>(null);
	const [exitingIdx, setExitingIdx] = useState<number | null>(null);
	const containerRef = useRef<HTMLDivElement>(null);
 
	useEffect(() => {
		if (isOpen) {
			setEntries(loadHistory());
			setConfirmingIdx(null);
			setExitingIdx(null);
		}
	}, [isOpen]);
 
	useEffect(() => {
		if (!isOpen) return;
		function handleMouseDown(e: MouseEvent) {
			if (
				containerRef.current &&
				!containerRef.current.contains(e.target as Node)
			) {
				setIsOpen(false);
			}
		}
		document.addEventListener("mousedown", handleMouseDown);
		return () => document.removeEventListener("mousedown", handleMouseDown);
	}, [isOpen]);
 
	useEffect(() => {
		if (!isOpen) return;
		function handleKeyDown(e: KeyboardEvent) {
			if (e.key === "Escape") setIsOpen(false);
		}
		document.addEventListener("keydown", handleKeyDown);
		return () => document.removeEventListener("keydown", handleKeyDown);
	}, [isOpen]);
 
	// ... 100+ lines of JSX
}

That's five built-in hooks called directly inside the component — an obvious violation. The fix that satisfies it is simple: move everything into one hook.

function useHistoryDropdown() {
	const [isOpen, setIsOpen] = useState(false);
	const [entries, setEntries] = useState<HistoryEntry[]>(() => loadHistory());
	const [confirmingIdx, setConfirmingIdx] = useState<number | null>(null);
	const [exitingIdx, setExitingIdx] = useState<number | null>(null);
	const containerRef = useRef<HTMLDivElement>(null);
 
	// ...same three effects, unchanged...
 
	return {
		confirmingIdx,
		containerRef,
		entries,
		exitingIdx,
		handleConfirmDelete,
		isOpen,
		setConfirmingIdx,
		setIsOpen,
	};
}

This passes the rule technically — prefer-custom-hooks only checks for a built-in hook being called directly inside a component. useHistoryDropdown does just that, but this doesn't satisfy what I'd argue is the spirit of the rule. Look at the name: useEncapsulation. The hooks are grouped up now, but not the behaviors they drive. What the rule has no way to check is whether the custom hook you wrote actually deserves its name — whether it's encapsulating one real concern or two unrelated ones that happen to share a function body. useHistoryDropdown is the second case: dropdown open/close mechanics and history data management, stapled together because they lived in the same component, not because they belonged together.

So the hook got split for real — dropdown mechanics in one hook, history data in another:

function useDropdownOpen() {
	const [isOpen, setIsOpen] = useState(false);
	const containerRef = useRef<HTMLDivElement>(null);
 
	useEffect(() => {
		if (!isOpen) return;
		function handleMouseDown(evt: MouseEvent) {
			if (
				containerRef.current &&
				!containerRef.current.contains(evt.target as Node)
			) {
				setIsOpen(false);
			}
		}
		function handleKeyDown(evt: KeyboardEvent) {
			if (evt.key === "Escape") setIsOpen(false);
		}
		document.addEventListener("mousedown", handleMouseDown);
		document.addEventListener("keydown", handleKeyDown);
		return () => {
			document.removeEventListener("mousedown", handleMouseDown);
			document.removeEventListener("keydown", handleKeyDown);
		};
	}, [isOpen]);
 
	return { containerRef, isOpen, setIsOpen };
}

The history side kept its own state — saved entries, plus a draft entry from an autosave feature that had landed a bit later. The draft got handled the way it's easiest to handle a new thing: with parallel state of its own.

function useHistoryState(isOpen: boolean) {
	const [entries, setEntries] = useState<HistoryEntry[]>(() => loadHistory());
	const [draft, setDraft] = useState<HistoryEntry | null>(() => loadDraft());
	const [confirmingIdx, setConfirmingIdx] = useState<number | null>(null);
	const [exitingIdx, setExitingIdx] = useState<number | null>(null);
	const [confirmingDraft, setConfirmingDraft] = useState(false);
	const [exitingDraft, setExitingDraft] = useState(false);
 
	// ...refresh effect, now resetting six pieces of state instead of two...
 
	function handleConfirmDeleteDraft() {
		/* ... */
	}
 
	return {
		confirmingDraft,
		confirmingIdx,
		draft,
		entries,
		exitingDraft,
		exitingIdx,
		handleConfirmDelete,
		handleConfirmDeleteDraft,
		setConfirmingDraft,
		setConfirmingIdx,
	};
}

The tell wasn't the new feature arriving. It was that return statement — ten things coming out of one hook, and half of them were just the other half with "Draft" stuck on the end. The render code that consumed it made it unmistakable:

<div className="relative">
	<div
		className={`divide-y divide-gray-100 overflow-y-auto dark:divide-white/10 ${totalCount > 5 ? "max-h-64" : ""}`}
	>
		{totalCount === 0 ? (
			<div className="flex flex-col items-center justify-center gap-1 px-3 py-6 text-center">
				<p className="text-xs text-gray-500 dark:text-gray-400">
					No history yet
				</p>
				<p className="text-xs text-gray-400 dark:text-gray-500">
					Changes autosave as you work
				</p>
			</div>
		) : (
			<>
				{draft !== null && (
					<HistoryRow
						entry={draft}
						isConfirming={confirmingDraft}
						isExiting={exitingDraft}
						onRestore={() => {
							restore(draft.state);
							setIsOpen(false);
						}}
						onRequestDelete={() => {
							setConfirmingDraft(true);
						}}
						onConfirmDelete={handleConfirmDeleteDraft}
						onCancelDelete={() => {
							setConfirmingDraft(false);
						}}
					/>
				)}
				{entries.map((entry, idx) => (
					<HistoryRow
						key={entry.timestamp}
						entry={entry}
						isConfirming={confirmingIdx === idx}
						isExiting={exitingIdx === idx}
						onRestore={() => {
							restore(entry.state);
							setIsOpen(false);
						}}
						onRequestDelete={() => {
							setConfirmingIdx(idx);
						}}
						onConfirmDelete={() => {
							handleConfirmDelete(idx);
						}}
						onCancelDelete={() => {
							setConfirmingIdx(null);
						}}
					/>
				))}
			</>
		)}
	</div>
</div>

Two render branches, structurally identical down to the prop names. That's not two features sharing a list, that's one thing, copy-pasted under a second name. draft and entries were never really two things; they were one thing pretending to be two. The component also now had to know drafts existed at all, which is not its job.

Two things got fixed in the same pass. The first was a naming problem I didn't see until I was inside the hook. The parameter had been called isOpen, because that's what the component calls it — but useHistoryState doesn't care whether the dropdown is open, it cares whether it should go re-fetch its data. Once I was looking at the hook on its own, isOpen was the caller's name for the value, not the hook's reason for wanting it. The hook wanted to know when it shouldRefresh, and the component knows that time is when the dropdown isOpen: useHistoryState({ shouldRefresh: isOpen }). The second fix was collapsing draft and entries into one flat list inside the hook, with the question of which index is the draft handled by index math that never leaves the hook:

function useHistoryState({ shouldRefresh }: { shouldRefresh: boolean }) {
	const [entries, setEntries] = useState<HistoryEntry[]>(() => loadHistory());
	const [draft, setDraft] = useState<HistoryEntry | null>(() => loadDraft());
	const [confirmingIdx, setConfirmingIdx] = useState<number | null>(null);
	const [exitingIdx, setExitingIdx] = useState<number | null>(null);
 
	useEffect(() => {
		if (shouldRefresh) {
			setEntries(loadHistory());
			setDraft(loadDraft());
			setConfirmingIdx(null);
			setExitingIdx(null);
		}
	}, [shouldRefresh]);
 
	const allEntries = draft !== null ? [draft, ...entries] : entries;
 
	function handleConfirmDelete(idx: number) {
		const isDraftEntry = draft !== null && idx === 0;
		// ...routes internally, the index math never leaves the hook...
	}
 
	return {
		confirmingIdx,
		entries: allEntries,
		exitingIdx,
		handleConfirmDelete,
		savedCount: entries.length,
		setConfirmingIdx,
	};
}

Now the component just maps over entries once, no longer worrying about drafts. useHistoryState and useDropdownOpen are each in their own box, and the component only knows what it needs to know: that there are entries to render, and when to tell the history hook to refresh. Essentially, the right trigger for splitting something isn't "this is too long," it's "this has a name worth giving." Here, that was the dropdown's open/closed state mixing with history management under one name that only half-described either. The same smell shows up in components, not just hooks — a component doing too many things can usually be split the same way, by giving each thing its own name.

Control flow that states all outcomes

Douglas Crockford is arguably most famous for discovering that JavaScript has good parts. In doing so, he also discovered the bad parts. His heuristic for telling them apart has stuck with me: "If a feature is sometimes useful and sometimes dangerous and if there is a better option then always use the better option." Three habits below are that same heuristic, applied at different scales.

  1. The && pattern. It's everywhere in React codebases, and it's shorter to write than the ternary I actually reach for — which is exactly the trade Crockford's heuristic is about. && doesn't return a boolean, it returns whichever operand it lands on, and that distinction doesn't matter until the left side is a legitimate 0:
{
	dryerKw && <Dryer />;
}
// renders the literal "0" on the page when dryerKw is 0 — falsy isn't the same as nothing
 
{
	dryerKw === null ? null : <Dryer />;
}
// renders nothing when dryerKw is 0, because the check is for the actual condition, not falsiness

I prefer to consider the unhappy path first — I'm liable to forget it otherwise — so the ternary is the default I reach for, not the exception.

  1. if/else over early returns. A talk by Kevlin Henney makes the case that indentation is doing for code what grammar does for a sentence — it's not decoration, it's how a reader tracks what's true at any given point without having to hold it all in their head. Block bodies guarantee that the indentation means something. Early returns don't, and the gap shows up the moment a function grows:
// early returns
function onDropdownOpen(isOpen: boolean) {
	if (!isOpen) return;
 
	const history = loadHistory();
	if (history.length < 1) {
		setEntries([]);
		return;
	}
 
	setEntries(history); // looks unconditional — actually needs two things to be true
	setConfirmingIdx(null);
 
	if (history.length >= MAX_HISTORY) {
		pruneHistory();
		setEntries(loadHistory());
	}
}
// if/else — indentation matches what has to be true
function onDropdownOpen(isOpen: boolean) {
	if (isOpen) {
		const history = loadHistory();
 
		if (history.length < 1) {
			setEntries([]);
		} else {
			setEntries(history); // two levels deep, both conditions visible
			setConfirmingIdx(null);
 
			if (history.length >= MAX_HISTORY) {
				pruneHistory();
				setEntries(loadHistory());
			}
		}
	}
}

setEntries(history) needs the dropdown open and history non-empty to ever run. In the early-return version it sits at the function's base indentation anyway, as if it needs nothing. In the if/else version it's two levels deep, where it actually belongs. The if/else form also leads naturally with the short path — fewer than one entry, clear the list, done — before the branch that does more. Early returns get credit for this same ordering, but it isn't really theirs to claim. You can lead with the failure case inside if/else just as easily; it's a separate habit that happens to fit comfortably here, not a property of early returns.

I held onto guard clauses longer than the rest of this, because they felt like a legitimate exception rather than a shortcut — short, flat, no real cost. What changed my mind was trying to write down the actual rule for when a guard clause earns its keep, and noticing the rule was doing more work than the guard clause was. "Keep it simple, keep it at the top of the function, don't let it get complex" is a rule about how to use a tool safely. A block body doesn't need that rule. It announces itself, indentation and all, the same way every time, on a one-line function or a hundred-line one. Nothing is gained by allowing the exception, and a small thing is lost: the version of the code where you have to ask "is this guard clause still simple enough to be one" disappears entirely.

What finally settled it was a case that looked like it needed guard clauses for a good reason — not brevity, something real:

function handleRequest(req) {
	if (!req.user) return errorResponse(401);
	if (!req.user.isVerified) return errorResponse(403);
	if (!req.body) return errorResponse(400);
	if (!isValidPayload(req.body)) return errorResponse(422);
	if (rateLimiter.isLimited(req.user.id)) return errorResponse(429);
 
	return process(req);
}

Forcing this into if/else buries process(req) five levels deep, behind nesting that implies each check depends on the one before it:

function handleRequest(req) {
	if (req.user) {
		if (req.user.isVerified) {
			if (req.body) {
				if (isValidPayload(req.body)) {
					if (!rateLimiter.isLimited(req.user.id)) {
						return process(req);
					} else {
						return errorResponse(429);
					}
				} else {
					return errorResponse(422);
				}
			} else {
				return errorResponse(400);
			}
		} else {
			return errorResponse(403);
		}
	} else {
		return errorResponse(401);
	}
}

But that dependency isn't real. Rate limiting doesn't care whether the payload's been validated yet — these five checks could run in any order and handleRequest would behave identically. That's the actual test: if reordering the conditions changes nothing, they were never a hierarchy, and nesting them invents one that isn't there. The dropdown example doesn't have this problem — history only matters because the dropdown's open, pruning only matters because history exists, the order is the logic.

So this isn't a case for keeping the guard clause around as an exception. It's a case for naming the thing, the same move from the dropdown hooks:

function handleRequest(req) {
	const error = validateRequest(req);
 
	if (error) {
		return error;
	} else {
		return process(req);
	}
}

One guard clause, because there's genuinely one condition now — validateRequest either failed or it didn't. Everything the five checks were doing still happens, it just has a name and a home of its own instead of living as a row of exits at the top of a handler. It's the same pattern frameworks reach for with middleware — app.route("/thing", userExists, userIsVerified, isNotRateLimited, requestBodyIsValid, ...) reads like a sentence precisely because nobody had to nest five unrelated checks to express "all of these have to pass." What looked like a good argument for guard clauses turned out to be a good argument for extraction. Once you extract, if/else costs nothing, because there's nothing left to flatten.

There's a smaller, quieter reason to drop the exception even where it costs nothing either way: leaving the choice open means everyone re-decides it for themselves, function by function, and a codebase starts to feel like several people's opinions rather than one. Removing the choice is its own small instance of Explicit by Default — the default exists so nobody has to litigate the question every time.

  1. Arrow function shorthand. {} after an arrow is parsed as a function body, not an object literal — () => {} silently returns undefined, and getting an actual object back requires () => ({}), parens that are optional everywhere else. That's a rule with exactly one silent exception, which is exactly the kind of thing I don't trust myself to remember every time. Writing the explicit body removes the judgment call entirely:
() => {}              // returns undefined — looks like it should be an empty object
() => ({})             // returns {} — parens required here, optional everywhere else
() => { return {}; }   // returns {}, no special case to remember