YAGNI: Build Only What You Need
What five years of React taught me about over-engineering.

Search for a command to run...
What five years of React taught me about over-engineering.

No comments yet. Be the first to comment.
Building a real, reusable SwiftUI modifier from scratch.

Stop wasting time on the wrong things and start delivering real value

Exploring hoisting and scoping in javascript with most commonly asked interview questions

A Beginner's Guide to Object-Oriented Programming Essentials

YAGNI stands for "You Aren't Gonna Need It." It came out of Extreme Programming, and the rule is short: don't build a thing until you need it. Thinking you'll need it doesn't count.
That feels too obvious to write down. And yet every React codebase I've worked in carries code for requirements that never showed up. The flexible component nobody flexes. The Redux store wrapped around one login form. Config options that have held the same value for two years. None of it got written out of laziness. It got written by people trying to be good engineers, which is exactly why the habit is hard to see in yourself.
React rewards abstraction. Components compose, props generalise, hooks extract logic. The whole mental model is built around "make a reusable piece." That's a strength, but it quietly trains a reflex: the moment you see two things that look similar, you want to unify them. The moment you write a component, you want to make it configurable.
The trouble is that flexibility nobody uses is just complexity. It still has to be read, tested, and maintained by whoever opens that file next, which is usually you, eight months later, with no memory of why any of it is there.
So let's go through the patterns where this happens most, with concrete before-and-after code.
Early in my career, I'd write a Button like this on day one of a project:
// What I used to write immediately
function Button({
variant = 'primary',
size = 'medium',
icon,
iconPosition = 'left',
loading = false,
fullWidth = false,
rounded = false,
as: Component = 'button',
...rest
}) {
const classes = clsx(
'btn',
`btn--${variant}`,
`btn--${size}`,
fullWidth && 'btn--full',
rounded && 'btn--rounded',
loading && 'btn--loading'
);
return (
<Component className={classes} {...rest}>
{loading && <Spinner />}
{icon && iconPosition === 'left' && icon}
{rest.children}
{icon && iconPosition === 'right' && icon}
</Component>
);
}
Look at it. It's a Swiss Army knife. It handles icon positioning, polymorphic rendering via as, loading states, four size options. And at the point I wrote it, the app had two buttons: a blue "Save" and a grey "Cancel."
Here's what YAGNI says to write instead:
function Button({ variant = 'primary', ...rest }) {
return <button className={`btn btn--${variant}`} {...rest} />;
}
That's it. When a design actually requires an icon button, I add icon support then, and I'll add it correctly, because I'll be looking at the real use case instead of guessing at it. The version I imagined in advance is almost always wrong anyway: real designs wanted a loading state that disabled the button and swapped the label, not a spinner floating next to the text. My speculative loading prop didn't even fit the requirement when it finally arrived.
The lesson: every prop is a promise. It's a branch you now have to support, document, and test forever. Don't make promises the product hasn't asked you to keep.
The most expensive version of this, and the one I see most often. A team starts a project and the first architectural decision is which state library to install. Redux Toolkit, Zustand, Jotai, whichever. It gets wired up before a single screen exists that needs it.
Here's a login form modelled in a global store:
// store/authForm.js: the speculative version
import { createSlice } from '@reduxjs/toolkit';
const authFormSlice = createSlice({
name: 'authForm',
initialState: { email: '', password: '', errors: {} },
reducers: {
setEmail: (state, action) => { state.email = action.payload; },
setPassword: (state, action) => { state.password = action.payload; },
setErrors: (state, action) => { state.errors = action.payload; },
reset: () => ({ email: '', password: '', errors: {} }),
},
});
Plus the provider setup, the selectors, the dispatch wiring in the component, and the mental tax of knowing that this form's state now lives in a global object that anything in the app can read and write.
What does this form actually need? Local state:
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
// ...handlers
}
One component uses this state and it's discarded the moment the user navigates away. That's local state by definition. Putting it in a store buys nothing today and costs indirection on every read.
The pushback is always "what if we need to share auth state later?" You probably will. But the thing you'll share is the logged-in user, not the in-progress password field, and the speculative store had already blurred those two together. When the real need shows up, lifting state or adding a context is a ten minute job. Doing it early saves you nothing. It just moves the cost forward onto a design you're inventing.
I start every project now with useState and useContext, and reach for a library when I hit the problem it solves: prop-drilling pain across many layers, or server cache, where React Query is a specific tool for a specific job rather than speculative plumbing. Most small and medium apps never get there.
Speculative abstraction loves to hide inside custom hooks. Here's a fetch wrapper I genuinely shipped once:
// useApi.js: built to handle "any future endpoint"
function useApi(endpoint, { method = 'GET', transform, retries = 3,
cache = true, dedupe = true, pollInterval } = {}) {
// ...120 lines of retry logic, caching layer, polling,
// request deduplication, and a transform pipeline
}
We used it to fetch a list of users. Once. With a plain GET. The retry logic was never exercised, the polling was never turned on, the cache had a subtle bug nobody noticed for months because nothing ever read from it twice. I'd built a framework to solve problems we did not have.
The honest version keeps error handling, because a failed request is a present-day reality, not a speculative future. What it drops is the retries, caching, and polling nobody asked for:
function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then((r) => {
if (!r.ok) throw new Error('Failed to load users');
return r.json();
})
.then(setUsers)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { users, loading, error };
}
Notice the difference between the two things I cut and the one thing I kept. The retries, cache, and polling were speculative, so they went. The error state stayed, because the request can fail the very first time it runs. YAGNI removes the imagined future; it never removes the present-day requirement. That distinction is the whole game, and we'll return to it shortly.
When the second endpoint appeared, I didn't rush to unify them; I wrote a second small hook. It was only at the third or fourth one, when a real, repeated pattern had emerged from real usage, that I extracted a shared abstraction. And that abstraction was better, because it was shaped by three actual call sites instead of one imagined future.
This is the deepest idea in the whole post, so I'll state it plainly: the right time to abstract is when you have repetition, not when you anticipate it. The "Rule of Three" exists for this reason. Two similar things might be a coincidence. Three is a pattern you can trust. Abstracting on the first occurrence means abstracting on a sample size of one: you're not designing for reality, you're designing for a hunch.
React developers have a particular flavour of this: wrapping everything in useMemo, useCallback, and React.memo "to be safe."
function ProductList({ products }) {
// every one of these is speculative
const sorted = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
const handleClick = useCallback((id) => navigate(`/product/${id}`), []);
return sorted.map((p) => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
));
}
If this list renders a dozen items and re-renders rarely, the memoisation makes the code slower to read and no faster to run. Both hooks carry their own bookkeeping, and worse, they carry a question: every future reader has to work out why this value is memoised and whether the dependency array is right.
YAGNI here means: write the plain version, and reach for memoisation when you have a measured problem, such as a profiler flame graph showing a real re-render cost, or a genuinely expensive computation on a genuinely large list. "It might get slow" is not a measurement. Optimise the slowness you can observe, not the slowness you can imagine.
I have to be careful here, because YAGNI is easy to weaponise into an excuse for sloppiness, and that's a misreading. So let me draw the line clearly, with honesty about the trade-offs.
YAGNI is about speculative features and speculative abstraction: capability you're adding for an imagined future. It is not a licence to skip things you need right now:
Error handling is not YAGNI. If your fetch can fail, and it can, handling the failure is a present need, not a speculative one. (This is exactly why the error state stayed in Example 3.)
Accessibility is not YAGNI. A button that real users will click needs to be operable today. That's a current requirement.
Tests for the code you're writing are not YAGNI. You're shipping the behaviour now, so you verify the behaviour now.
Security and input validation are not YAGNI. The malicious input isn't hypothetical; it's a present-tense risk.
The distinction is time. YAGNI asks: "Is this solving a problem I have, or a problem I'm guessing I might have?" Error handling solves a problem you have the instant the code runs. A polymorphic as prop solves a problem nobody has reported. One is need, the other is anticipation.
There's a real counter-pressure worth naming too: some decisions are much cheaper early than late. Database schema, public API contracts, core data models. Those have high reversal costs and deserve upfront thought. YAGNI applies most cleanly to things that are cheap to change later, which is what most React component and state decisions are. Refactoring a component takes minutes. That low cost of change is precisely what makes deferring correct. Know which kind of decision is in front of you.
Why does any of this matter enough to write a whole post about? Because speculative code isn't neutral. It charges rent.
Every speculative prop, abstraction, and config option you add becomes something that:
Someone has to read to understand the file, increasing the time-to-comprehension for every future change.
Someone has to test, or worse, doesn't test, leaving dead branches that silently rot.
Constrains future changes, because now there's an abstraction in the way that has to be honoured or unwound.
Misleads the next developer, who reasonably assumes the flexibility exists because something uses it, and wastes time preserving behaviour nothing depends on.
That last one is the silent killer. Unused flexibility isn't just dead weight; it actively lies to the people who come after you about what the system needs to do.
You don't need to refactor your whole codebase. YAGNI is a habit, and you build it one decision at a time. Here's what to do starting on your next pull request:
Ask one question before every abstraction: "Do I have a real, present use for this, or am I guessing?" If it's a guess, write the simple, specific version. That single question prevents most over-engineering.
Default to local, specific, and concrete. Start with useState, not a store. Start with a hardcoded value, not a config object. Start with one component, not a configurable family. You can always generalise later, and "later" will hand you better information than you have now.
Delete the props nobody passes. Open a component you wrote a while ago and search the codebase for each prop. The ones with a single call site that never vary? Inline them. This is a five-minute task with an instant payoff in readability, and it teaches you how much speculative surface area you tend to add.
Wait for the third occurrence before extracting. When you feel the urge to DRY up two similar pieces of code, resist it once. Let the duplication sit until a third case shows you the actual shape of the pattern. A little duplication is far cheaper than the wrong abstraction.
Optimise only what you've measured. Before adding useMemo or React.memo, open the profiler. No flame graph, no memoisation.
Each of these is small. None requires permission or a big refactor. But together they change how it feels to work in your code: files get shorter, intent gets clearer, and the gap between "I understand this" and "I can change this safely" nearly disappears.
For my first couple of years, I measured my skill by how much architecture I could anticipate. The senior move, I thought, was foreseeing every future need and building for it. Five years in, I've inverted that completely. The senior move is building exactly what's needed, as simply as possible, and trusting yourself to change it when reality asks you to.
Good engineering isn't predicting the future. It's keeping the present so clean and simple that whatever future shows up is cheap to adapt to. YAGNI is how you do that. Write the simple thing. Wait for the real need. You'll ship faster, your teammates will thank you, and you'll spend far less time maintaining code that exists only to serve a future that never came.
Start with your next component. Build only what it needs. You aren't gonna need the rest.