Skip to main content

Command Palette

Search for a command to run...

YAGNI: Build Only What You Need

What five years of React taught me about over-engineering.

Updated
13 min readView as Markdown
YAGNI: Build Only What You Need
M
I am a front-end software developer and user experience designer based in India. I have worked extensively on creating inspiring web experiences and wish to share my knowledge through these blogs.

The principle in one sentence

YAGNI stands for "You Aren't Gonna Need It." It comes from Extreme Programming, and it says one thing: don't build something until the moment you actually need it. Not when you think you'll need it. Not when it would be "nice to have." When you need it.

That sounds almost too obvious to write down. Yet a surprising amount of code in React codebases exists for requirements that never materialised. The "flexible" component nobody flexes, the Redux store around a simple form, the configuration options that never change. We don't write this code because we're careless. We write it because we're trying to be good engineers. YAGNI is the discipline of noticing when that instinct is working against you.

Let me show you what I mean, because the principle only clicks once you see it in real code.


Why React developers fall into this trap harder than most

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 problem is that flexibility you don't use isn't flexibility; it's just complexity. And complexity has to be read, tested, and maintained by every person who touches that file afterwards, including future you who has forgotten why any of it is there.

So let's go through the patterns where this happens most, with concrete before-and-after code.


Example 1: The component built for buttons that don't exist

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.


Example 2: Reaching for global state on day one

This is the most expensive YAGNI violation I see, and I see it constantly. A team starts a project and the first architectural decision is "we need a state management library." Redux Toolkit, Zustand, Jotai (pick your flavour) gets installed and wired up before there's a single screen 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
}

The form state is used in exactly one component and is thrown away the moment the user navigates. That is the textbook definition of local state. Putting it in a global store buys you nothing today and costs you indirection on every read.

The counterargument is always: "But what if we need to share auth state across the app later?" And you might! But notice two things. First, the thing you'll eventually share is the logged-in user, not the form's in-progress password field. Those are different concerns, and the speculative store conflated them. Second, when that real need arrives, lifting state up or adding a context takes about ten minutes. You are not saving yourself meaningful work by doing it early; you are only paying the cost early, on a design you're guessing at.

I now start every project with useState and useContext. I reach for a state library when I hit a concrete problem they solve, usually prop-drilling pain across many layers, or server-cache needs (where I reach for React Query, which is a specific tool for a specific job, not speculative plumbing). Most small-to-medium apps never hit that point.


Example 3: The "what if we need it in another language / theme / API" hooks

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.


Example 4: Premature performance optimisation

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 ProductList renders a dozen items and re-renders rarely, these hooks make the code slower to read and no faster to run. useMemo and useCallback are not free: they add their own bookkeeping, and more importantly they add cognitive overhead, because now every reader has to wonder why this value is memoised and whether the dependency array is correct.

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.


What YAGNI is not

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 also a genuine counter-pressure worth naming: some changes are far cheaper to make early than late. A database schema, a public API contract, a core data model: these have high reversal costs, and a little upfront thought is warranted. YAGNI applies most cleanly to things that are cheap to change later, which is exactly what most React component and state decisions are. A component is a few minutes to refactor. That low cost of change is precisely what makes deferring the right call. Know which kind of decision you're making.


The real cost of "just in case" code

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:

  1. Someone has to read to understand the file, increasing the time-to-comprehension for every future change.

  2. Someone has to test, or worse, doesn't test, leaving dead branches that silently rot.

  3. Constrains future changes, because now there's an abstraction in the way that has to be honoured or unwound.

  4. 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.


How to start today

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.


The mindset shift

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.