There's a pattern I see in almost every team I join: a developer discovers a powerful abstraction — Redux, MobX, XState, a custom event bus — and installs it "because we'll need it eventually." Six months later, the team is maintaining 200 lines of boilerplate for state that could live in a useState call. Nobody remembers why the store exists. Nobody dares to remove it.
This is the opposite of good engineering. Good engineering is choosing the simplest tool that solves the actual problem. KISS — Keep It Simple, Stupid — isn't a cute acronym. It's the single most undervalued principle in software development, especially in the React ecosystem where the tooling landscape actively encourages over-engineering.
The Cleverness Trap
The Dunning-Kruger effect hits hardest in state management. A developer who just learned Redux thinks every piece of state belongs in a global store. They create action types, reducers, selectors, middleware — for a search filter that's used by two components on one page. It feels productive. It feels professional. It's none of those things.
Clever code is a liability. Every abstraction you introduce is a concept your team has to learn, maintain, and debug. Redux adds actions, reducers, selectors, middleware, and a store configuration layer. That's five new concepts for state management — in a framework that already has built-in state management. You're paying a complexity tax on every feature you build, and the return on that investment is rarely positive.
I've joined projects at AXA and at a major Swiss bank where removing Redux entirely — replacing it with useState and useContext — reduced the state management code by 70% and made features faster to build. Not because Redux is bad software. Because it was the wrong tool for what those applications actually needed.
// Redux approach for a simple filter + list UI
// store/filtersSlice.ts
const filtersSlice = createSlice({
name: 'filters',
initialState: { search: '', category: 'all' },
reducers: {
setSearch: (state, action) => { state.search = action.payload; },
setCategory: (state, action) => { state.category = action.payload; },
resetFilters: (state) => { state.search = ''; state.category = 'all'; },
},
});
// store/index.ts — configure store, combine reducers
// store/hooks.ts — typed useSelector, useDispatch
// components/Filters.tsx — useSelector + useDispatch
// components/List.tsx — useSelector to read filters
// 5 files, 80+ lines of boilerplate for two strings.
// The same thing with useState:
function ProductPage() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
return (
<>
<Filters search={search} category={category}
onSearch={setSearch} onCategory={setCategory} />
<ProductList search={search} category={category} />
</>
);
}
// 1 file. 10 lines. Same result. Easier to read, easier to change.React State Is Enough (Most of the Time)
React has two built-in state primitives: useState for local state, and useContext for shared state. Together, they cover the vast majority of real-world applications. Before reaching for any external library, ask yourself: can I solve this with what React already gives me?
useState handles component-local state — form inputs, toggles, loading flags, selected tabs. useContext handles state that multiple components need — the current user, a shopping cart, a theme preference. Combine them and you have a state management solution that's zero dependencies, zero boilerplate, and understood by every React developer on the planet.
The pushback I hear is always about "scale." But I've worked on large applications at Vontobel and Migros — real-time trading platforms, multi-brand e-commerce — and Context + useState scaled fine. The key is placing your providers strategically: not one giant context at the root, but focused contexts for each domain. A CartProvider wraps the checkout flow. A ThemeProvider wraps the app. A DashboardProvider wraps the dashboard page. Each one is small, focused, and easy to reason about.
// When multiple components need the same data and
// props drilling goes beyond 3 levels — use Context.
interface CartContext {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: number;
}
const CartCtx = createContext<CartContext | null>(null);
function useCart() {
const ctx = useContext(CartCtx);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}
// Smart component: owns the state, provides it via context
function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const addItem = (item: CartItem) =>
setItems((prev) => [...prev, item]);
const removeItem = (id: string) =>
setItems((prev) => prev.filter((i) => i.id !== id));
const total = items.reduce((sum, i) => sum + i.price, 0);
return (
<CartCtx.Provider value={{ items, addItem, removeItem, total }}>
{children}
</CartCtx.Provider>
);
}
// Any descendant can read cart state — no prop chains
function CartBadge() {
const { items } = useCart();
return <span>{items.length}</span>;
}
function CartTotal() {
const { total } = useCart();
return <span>{formatCurrency(total)}</span>;
}The Component Tree Is Your State Manager
This ties directly into the smart and dumb component split I've written about before. The component tree is not just a rendering hierarchy — it's a state distribution system. Smart components at the top own state and pass it down. Dumb components below receive data and render it.
When you put state in the right place — the lowest common ancestor of the components that need it — you don't need a store. The React tree does the distribution for free. A DashboardPage component that fetches data and passes it to StatsGrid and ChartSection is doing exactly what Redux would do, but with zero ceremony.
This is what I mean by "the dumbest code is the smartest." A smart component with useState and three props passed to children is boring. It's obvious. A new developer can read it in 30 seconds. That's exactly what you want. The Next.js cart rewrite I described in Forget Best Practices followed the same principle — we replaced server-rendered complexity with simple client-side state, and everything got better.
// The component tree IS your state architecture.
// Place state in the lowest common ancestor that needs it.
// ❌ Over-engineered: global store for page-local state
// Redux store → useSelector in Header, Sidebar, Content
// Now every component is coupled to a global store shape
// and re-renders on any store change unless you memoize everything.
// ✅ Simple: smart component owns the state, passes it down
function DashboardPage() {
const [period, setPeriod] = useState<'week' | 'month' | 'year'>('month');
const [data, setData] = useState<DashboardData | null>(null);
useEffect(() => {
fetchDashboard(period).then(setData);
}, [period]);
return (
<main>
{/* 1 level deep — just pass it */}
<PeriodSelector period={period} onChange={setPeriod} />
<StatsGrid data={data} />
<ChartSection data={data} period={period} />
</main>
);
}
// PeriodSelector, StatsGrid, ChartSection are all dumb.
// They receive data, render UI, report interactions.
// No store subscription. No selector. No dispatch.
// Change the data shape? Update one smart component.Props, Context, or Both
I enforce a simple rule on my teams: props drilling is fine up to 3 levels deep. Beyond that, use Context. This isn't arbitrary — it's the point where passing the same prop through intermediate components that don't use it starts hurting readability and creating unnecessary coupling.
Props are the default. They're explicit, traceable, and type-safe. When you see a component's props, you know exactly what it needs. That transparency is valuable — don't give it up for convenience.
Context is for when props create chains. When a Layout component has to accept a user prop just to pass it to a Navigation component that passes it to a UserMenu — that's noise. None of those intermediate components care about the user. Context lets you skip the chain and read the value directly where it's needed.
The sweet spot is mixing both. Use Context for cross-cutting concerns that many components need (user, theme, cart, locale). Use props for data that flows through a clear parent-child relationship. And never use either for state that belongs in a single component — that's what useState is for.
// Props drilling is fine — up to a point.
// My rule: max 3 levels deep. Beyond that, use Context.
// ✅ Fine — 2 levels
<Page>
<Section filters={filters}>
<FilterBar filters={filters} onChange={setFilters} />
</Section>
</Page>
// ❌ Too deep — 5 levels of threading the same prop
<Page>
<Layout user={user}>
<Sidebar user={user}>
<Navigation user={user}>
<UserMenu user={user}> {/* enough. */}
<Avatar user={user} />
</UserMenu>
</Navigation>
</Sidebar>
</Layout>
</Page>
// ✅ Context cuts the chain where it matters
<UserProvider user={user}>
<Layout>
<Sidebar>
<Navigation>
<UserMenu /> {/* useUser() — reads from context */}
</Navigation>
</Sidebar>
</Layout>
</UserProvider>
// The middle components (Layout, Sidebar, Navigation)
// no longer need to know about 'user' at all.
// They just render their children. Clean.Redundancy Over Abstraction
This article is the practical companion to Why YAGNI Beats DRY. The same principle applies to state management: a little redundancy is cheaper than the wrong abstraction. Two components that each manage their own local state with similar useState calls are easier to maintain than two components sharing a global store slice that was designed to "avoid duplication."
Duplication in state is visible and local. You can see it, and if the two states need to diverge (they usually do), you just change one without touching the other. A shared store creates invisible coupling — change the shape for one consumer and you break the other. I've watched teams spend entire sprints untangling shared Redux slices that were "saving code" but creating dependencies between unrelated features.
The best teams I've worked with default to simple. useState first. If multiple components need the same state, lift it up to a common parent and pass it down. If the prop chain gets too deep, wrap it in a Context. If — and only if — you genuinely need time-travel debugging, complex state machines, or optimistic updates across many entities, then consider a dedicated state library. But most applications never get there. And the ones that do usually need it in one part of the app, not everywhere.
Stop solving problems you don't have. The simplest solution that works is the best solution — and it almost always stays the best solution long after the clever one has become a maintenance nightmare.

