What I learned building and scaling applications at different sizes
How I Got Here
I saw a LinkedIn post the other day. It was a comparison of state management libraries ranked purely by bundle size. Context (0KB), Zustand (2.2KB), Redux (4.3KB).
I started writing a response, then realized I had too much to say. That comparison, while technically correct, misses everything.
The truth is, I've lived the full journey with all three. I've watched teams pick based on package size and regret it six months later. I've seen the opposite: teams over-engineer from day one and waste months fighting boilerplate. And I've seen teams pick exactly right and wonder why other teams struggle so much.
Over the years, I've watched teams struggle with the same questions:
- Should we use Context or something heavier?
- When does Zustand stop working and you need Redux?
- How do you migrate when you've picked wrong?
- Why does bundle size matter less than I thought it would?
This guide is what I wish someone had told me instead of just showing me a bar chart of package sizes. The right choice isn't about bytes. It's about use cases, team size, and complexity, and that's a much more interesting conversation.
Context API: When It Falls Apart
What It Is
The built-in React solution for prop drilling. No dependencies, uses React's own state management.
The Story
That LinkedIn post had me convinced. "0KB!" I thought. "Perfect."
I was 3 weeks into a new product with 15 components, and I decided to use React Context. No dependencies. Pure React. It made sense at the time.
Fast-forward six months. The app had grown to 200 components. A user toggled a feature flag, and the entire dashboard froze for 2-3 seconds. Profiling revealed something shocking: a single state change was triggering 180+ component re-renders. I spent three days trying to fix it with useMemo, memoization, and context splitting. By day three, I realized the 0KB saved at the start had cost me weeks of debugging and optimization later.
And here's the kicker: the bundle size difference never mattered. The actual cost wasn't in the bytes, it was in the developer time, the performance hit in production, and the stress of a frozen dashboard during a critical demo.
That experience taught me that choosing based on package size alone is like buying a car based on the weight of the paint job. You're optimizing for the wrong metric.
When to Use It
- Small feature flags - Theme switching, locale selection
- Infrequent updates - User data that changes rarely
- Zero dependencies requirement - Constrained environments
- Simple navigation state - Current page, sidebar open/closed
- Learning React - Teaching state management fundamentals
Critical Gotchas (Production Issues I've Hit)
1. Performance Death by Re-renders (The Big One)
// BAD: GOTCHA: All consumers re-render when ANY value changes
const Value = React.createContext(null);
function App() {
const [user, setUser] = useState({ name: 'John', id: 1 });
const [theme, setTheme] = useState('dark');
const [language, setLanguage] = useState('en');
return (
<Value.Provider value={{ user, theme, language }}>
<SlowComponent /> {/* Re-renders even if only 'theme' changed */}
</Value.Provider>
);
}
// Component with ANY consumer hook memoization won't help much
function SlowComponent() {
const { theme } = useContext(Value);
return <ExpensiveRender />; // Still re-renders!
}
Why This Matters: I've seen dashboards with hundreds of components where changing a single flag caused full re-renders. Profiling showed 200ms of wasted renders per interaction.
The Fix (Partially):
// Split contexts by update frequency
const UserContext = React.createContext(null);
const ThemeContext = React.createContext(null);
// Even this isn't foolproof - descendants still re-render
// You need useMemo AND proper memoization everywhere
Reality Check: This "fix" adds complexity and doesn't fully solve the problem. You'll spend more time optimizing than using a proper store.
2. Stale Closures in Async Operations
// BAD: GOTCHA: Closure captures stale context value
const MyContext = React.createContext(null);
function Component() {
const contextValue = useContext(MyContext);
useEffect(() => {
// contextValue here is "frozen" when effect runs
const timer = setTimeout(() => {
console.log(contextValue); // May be stale!
}, 5000);
return () => clearTimeout(timer);
}, []); // Empty deps = stale closure trap
return <div>{contextValue.data}</div>;
}
At Scale: Imagine analytics tracking with stale event context. You lose data integrity. Redux handles this better.
3. No Time-Travel Debugging
You cannot:
- Inspect state history
- Replay actions
- See what changed and when
- Debug why things updated
Redux DevTools shows you exactly what happened. Context gives you nothing.
4. Middle-Layer Subscribers Problem
// BAD: GOTCHA: Intermediate components always re-render
function Provider() {
return (
<ContextA.Provider value={a}>
<ContextB.Provider value={b}>
<Middleware /> {/* Always re-renders! */}
<Content />
</ContextB.Provider>
</ContextA.Provider>
);
}
Redux separates subscription logic entirely from rendering. Context doesn't.
5. No Computed/Derived State Management
// BAD: With Context, you must manually manage derived state
const UserContext = React.createContext(null);
function App() {
const [users, setUsers] = useState([]);
// This memo is easy to forget or get wrong
const sortedUsers = useMemo(() =>
users.slice().sort((a, b) => a.name.localeCompare(b.name)),
[users]
);
return (
<UserContext.Provider value={{ users, sortedUsers }}>
{/* Prop drilling or manual memo in 50 places */}
</UserContext.Provider>
);
}
Redux has selectors. Zustand has derived selectors. Context just has prop drilling.
6. Testing is a Nightmare
// With Context, you must wrap every test component
function renderWithContext(component) {
return render(
<UserContext.Provider value={mockValue}>
<ThemeContext.Provider value={mockTheme}>
<LocaleContext.Provider value={mockLocale}>
{component}
</LocaleContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
This boilerplate is fragile. One missing provider and your test fails mysteriously.
7. Library Integration Hell
// Third-party UI libraries don't integrate well
// They expect Redux or managed state, not Context
// You end up lifting state up constantly
In my experience, integrating UI libraries with Context was painful. Redux is an industry standard.
When NOT to Use Context
- Any realtime data (multiple updates per second)
- Complex app logic or derived state
- Team larger than 3-4 people
- Performance-critical features
- Any app where you'll want debugging
Zustand: The Sleeping Giant
What It Is
Lightweight store library inspired by Jotai. Single function to create stores. Minimal boilerplate.
The Story
After my Context disaster, I wasn't ready for Redux. The boilerplate seemed excessive for a team of five. Then I discovered Zustand, and it felt like relief.
Zustand let me write state management code that felt natural. No action types, no reducers, no 500 lines of middleware setup. I could ship features fast. The bundle size was tiny. Performance was excellent.
For the next two years, it was my go-to choice. I built three different products with it, and each one scaled smoothly up to about 200 to 300 components. Then I hit the ceiling.
On one project, different team members started handling async operations in completely different ways. One person used callbacks, another used promises, and a third added a custom middleware pattern. No two API calls worked the same way. The lack of enforced structure that felt so freeing at the start became a nightmare to maintain.
It wasn't Zustand's fault. It's just that Zustand is extremely flexible, and without discipline, that flexibility becomes chaos.
When to Use It
- Mid-size applications - 50-500 components
- Moderate complexity - More than Context, less than Redux
- Teams 5-15 people - Easy to understand, zero magic
- Performance matters - Zero wasted renders
- Fast MVPs - Get to market quickly
- TypeScript shops - Excellent type inference
Real-World Example
// Store definition - it's just a hook
import create from 'zustand';
const useUserStore = create((set) => ({
user: null,
theme: 'light',
// Actions are just functions
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
// Reset
reset: () => set({ user: null, theme: 'light' }),
}));
// Usage - simple and clean
function App() {
const user = useUserStore((state) => state.user);
const setUser = useUserStore((state) => state.setUser);
return <div>{user?.name}</div>;
}
Gotchas (Real Production Issues)
1. Subscription Explosion Without Proper Selectors
// BAD: GOTCHA: Re-renders even with selector if you're not careful
const useStore = create((set) => ({
user: { name: 'John', email: 'john@ex.com' },
settings: { theme: 'dark' }
}));
function Component() {
// This seems fine...
const { user } = useStore();
// But useStore() creates a new object every time!
// Better approach below
}
// GOOD: Better
function Component() {
// Selector gets only what you need, memoized properly
const userName = useStore((state) => state.user.name);
// Or use shallow equality for objects
const user = useStore((state) => state.user);
}
In Practice: I had to teach my team about selector patterns. Sloppy usage leads to performance problems.
2. Missing Middleware/Plugins Pattern
// Redux has middleware built-in for side effects
// Zustand... you handle it yourself
const useStore = create((set, get) => ({
savedItems: [],
// Side effects manually baked in
addItem: async (item) => {
// You manage async, persistence, logging, etc.
const saved = await api.save(item);
set((state) => ({ savedItems: [...state.savedItems, saved] }));
// Where does logging go? Where do you centralize error handling?
}
}));
This freedom is great until your codebase has 15 different patterns for the same thing.
3. DevTools Integration Not as Rich as Redux
// Redux DevTools is built for Redux
// Zustand has plugins, but they're not as powerful
import { devtools } from 'zustand/middleware';
const useStore = devtools(
create((set) => ({
// ...
}))
);
// Works, but the UI history isn't as detailed
// Doesn't show you middleware flow
In Practice: Redux DevTools integration is industry-standard. Zustand's is good, not exceptional.
4. No Built-in Action/Reducer Pattern
// Redux gives you:
// reducer(state, action) -> newState with complete history
// Zustand gives you:
// set() anywhere you want - flexibility but no structure
const useStore = create((set) => ({
counter: 0,
increment: () => set((state) => ({ counter: state.counter + 1 })),
// vs
decrement: () => set((state) => ({ counter: state.counter - 1 })),
// After 50+ actions, there's no clear pattern
// Redux enforces type safety on action flow
}));
Scaling Problem: As stores grow, you lose the "single source of truth" for actions that Redux gives you.
5. Async Complexity Grows Quickly
// BAD: Zustand + async = manual state management
const useUserStore = create((set, get) => ({
users: [],
loading: false,
error: null,
timestamp: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const data = await api.getUsers();
set({ users: data, loading: false, timestamp: Date.now() });
} catch (err) {
set({ error: err.message, loading: false });
}
},
// Now multiply this by 20 endpoints...
// You'll see inconsistencies appear
}));
// RTK Query would handle all of this with a single endpoint definition
// Including caching, deduplication, and automatic refetching
In Practice: For async-heavy apps (95% of real apps), RTK Query handles caching, deduplication, and refetching out of the box. With Zustand, you build all of that yourself, and it rarely ends up consistent across the codebase.
6. No Clear Testing Story for Complex State
// Testing Zustand stores is simple for happy path
// Complex scenarios get messy
test('complex user flow', () => {
const { result } = renderHook(() => useUserStore());
// But what about state management edge cases?
// Concurrent updates? Race conditions?
// Redux DevTools helps you see the issue
});
7. Immer Middleware Isn't Always Applied
// BAD: GOTCHA: You can mutate state if not careful
const useStore = create(
immer((set) => ({
user: { name: 'John', nested: { email: 'j@ex.com' } },
// This might seem fine:
updateEmail: (email) => set((state) => {
state.user.nested.email = email; // Only works with immer!
})
}))
);
// Without immer, you MUST use spread operators
// Easy to forget, causes bugs
Sweet Spot for Zustand
Best when:
- You want Redux-like power without the boilerplate
- Team is 5-20 people
- App complexity is medium
- You need great TypeScript support
- Performance is important but not the #1 concern
- DevOps overhead matters
Don't use when:
- Your app is huge (1000+ components), where Redux's structure helps
- Your team needs strict patterns, since Zustand is too flexible
- You have heavy async or side effects, where Redux middleware is better
- Browser DevTools debugging is critical
Redux: The Industry Standard
What It Is
Predictable state container with immutable updates, action dispatch, and reducers. The most mature ecosystem.
The Story
I resisted Redux for years. The boilerplate felt ridiculous for most use cases. For simple apps, it seemed like enterprise overkill. But then I joined a team working on a more complex application with eight engineers, and the lead insisted on Redux.
I was skeptical. Setting up Redux Toolkit, configuring selectors, writing tests: it all felt slow. But something interesting happened around week 3.
When a subtle bug appeared involving async state, the Redux DevTools made it visible in seconds. I could see exactly which actions dispatched and when, inspect the state at any point, and even replay the sequence of events. What would have been an hour-long debugging session with Zustand took ten minutes.
More importantly, everyone wrote async code the same way. The team used thunks consistently. Error handling was predictable. New team members could look at one example and understand the pattern for everything else.
The boilerplate felt less painful when I realized it wasn't boilerplate. It was structure. Structure that caught bugs at the source instead of letting them hide in production.
When to Use It
- Large applications - 500+ components
- Complex state logic - Lots of derived/computed state
- Team of 10+ people - Enforce consistent patterns
- Mission-critical data - E-commerce, financial
- Browser DevTools requirement - Time travel, action history
- Mature ecosystem needed - Libraries, middleware, plugins
- Async operations - RTK Query, createAsyncThunk, or Sagas
Real-World Example (Redux Toolkit)
// 1. Slice with createAsyncThunk - handles loading/error/success automatically
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const response = await api.getUsers();
return response.data;
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const usersSlice = createSlice({
name: 'users',
initialState: { list: [], loading: false, error: null },
reducers: {
// Sync actions auto-generated
clearUsers: (state) => { state.list = []; },
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.list = action.payload;
state.loading = false;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.error = action.payload;
state.loading = false;
});
},
});
export const { clearUsers } = usersSlice.actions;
export default usersSlice.reducer;
// 2. Selectors (memoized)
export const selectUsers = (state) => state.users.list;
export const selectLoading = (state) => state.users.loading;
export const selectSortedUsers = createSelector(
[selectUsers],
(users) => users.slice().sort((a, b) => a.name.localeCompare(b.name))
);
// 3. Usage in component
function UsersList() {
const dispatch = useDispatch();
const users = useSelector(selectSortedUsers);
const loading = useSelector(selectLoading);
useEffect(() => { dispatch(fetchUsers()); }, [dispatch]);
if (loading) return <Spinner />;
return users.map(u => <UserCard key={u.id} user={u} />);
}
Real-World Example (RTK Query): The Modern Async Story
This is where Redux really shines now. RTK Query eliminates almost all async boilerplate:
// 1. Define API: one file, all endpoints
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users',
providesTags: ['User'],
}),
getUserById: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
createUser: builder.mutation({
query: (newUser) => ({
url: '/users',
method: 'POST',
body: newUser,
}),
invalidatesTags: ['User'], // Auto-refetches getUsers!
}),
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
}),
}),
});
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useCreateUserMutation,
useUpdateUserMutation,
} = usersApi;
// 2. Store setup: one line to add
const store = configureStore({
reducer: {
[usersApi.reducerPath]: usersApi.reducer,
},
middleware: (getDefault) => getDefault().concat(usersApi.middleware),
});
// 3. Usage: look how clean this is
function UsersList() {
const { data: users, isLoading, error } = useGetUsersQuery();
const [createUser] = useCreateUserMutation();
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<>
<button onClick={() => createUser({ name: 'New User' })}>
Add User
</button>
{users.map(u => <UserCard key={u.id} user={u} />)}
</>
);
}
// What you get for free:
// GOOD: Automatic caching
// GOOD: Cache invalidation on mutations
// GOOD: Deduplication of identical requests
// GOOD: Loading/error/success states
// GOOD: Polling & refetch on focus
// GOOD: Optimistic updates
// GOOD: Full DevTools visibility
Why RTK Query changed the game: Compare this to the Zustand async example above. RTK Query gives you caching, deduplication, automatic refetching, and cache invalidation, all things you'd have to build yourself in Zustand. For any app with more than five API endpoints, this saves weeks of work.
Gotchas (Even Redux Has Them)
1. Boilerplate Explosion
// For ONE feature, you need:
// 1. Action types (3+ constants)
// 2. Action creators (maybe 3-5 functions)
// 3. Reducer logic (switch statement, spreads, etc.)
// 4. Selectors (multiple, with reselect)
// 5. Middleware (if async)
// This scales poorly. What should take 10 lines takes 200+
// I've seen teams spending 30% of their time fighting Redux structure
2. RTK Reduces Boilerplate, But Doesn't Eliminate Complexity
// RTK + RTK Query dramatically cuts boilerplate
// But you STILL need to understand:
// - configureStore() setup
// - useSelector / useDispatch hooks
// - Selector memoization for derived data
// - Tag-based cache invalidation logic (RTK Query)
// - Middleware pipeline (RTK Query adds its own)
// RTK Query specifically has its own learning curve:
// - providesTags / invalidatesTags for cache management
// - Optimistic updates require onQueryStarted callbacks
// - Polling, prefetching, and cache lifetime tuning
// It's significantly better than vanilla Redux, but the
// mental model is still heavier than Zustand's "just call set()"
3. Normalized State Complexity
// Redux demands normalized state for performance
// Normalized = harder to reason about
// BAD: Simple but slow:
{
users: [
{ id: 1, name: 'John', posts: [{ id: 1, title: '...' }, ...] }
]
}
// GOOD: Redux way (normalized):
{
entities: {
users: {
'1': { id: 1, name: 'John', postIds: [1, 2] }
},
posts: {
'1': { id: 1, title: '...', userId: 1 },
'2': { id: 2, title: '...', userId: 1 }
}
},
result: [1]
}
// This is more efficient but requires EntityAdapters and deep selectors
// New developers struggle with this structure
4. Action Coupling in Teams
// BAD: GOTCHA: Different teams duplicate action logic
// Team A does: dispatch({ type: 'UPDATE_USER', payload: {...} })
// Team B does: dispatch({ type: 'MODIFY_USER', payload: {...} })
// Team C does: dispatch({ type: 'PATCH_USER', payload: {...} })
// After 3 months, you have 500+ incompatible action types
// Redux doesn't prevent this - discipline does
// Redux Toolkit's slices help, but only if enforced strictly
5. Middleware Ordering Issues
// BAD: GOTCHA: Middleware order matters, isn't obvious
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(loggingMiddleware)
.concat(analyticsMiddleware)
.concat(crashReportingMiddleware),
});
// If one middleware fails, order affects behavior
// I've spent hours debugging middleware chain ordering issues
// Redux DevTools helps but requires deep knowledge
6. Selector Memoization is Fragile
// BAD: Forget reselect, lose performance with derived data
const selectSortedUsers = (state) =>
state.users.list.slice().sort((a, b) => a.name.localeCompare(b.name));
// ^ New array created every call = re-render every time!
// GOOD: Must use reselect for derived/computed data
import { createSelector } from 'reselect';
const selectSortedUsers = createSelector(
(state) => state.users.list,
(users) => users.slice().sort((a, b) => a.name.localeCompare(b.name))
// Only recomputes when users.list reference changes
);
// Simple property access is fine without reselect:
// const selectUser = (state) => state.users.user; // GOOD: No new object
// But any transformation or filtering NEEDS memoization
// Many Redux codebases have subtle performance bugs from missing this
7. DevTools Addiction
// Redux DevTools are amazing... until they're not
// You get:
// GOOD: Time travel debugging
// GOOD: Action history
// GOOD: State snapshots
// BAD: But NOT: Why did this re-render? (React DevTools helps)
// BAD: And NOT: Why did this action dispatch? (Needs middleware logging)
// Teams become dependent on DevTools
// Then they deploy to production where DevTools don't work
// Require different debugging skills for production issues
When Redux Actually Shines
Redux wins when:
- State is complex and interconnected
- 10+ people on the team (enforce patterns)
- Async operations dominate (middleware ecosystem)
- DevTools debugging is critical
- Historical action tracking is needed
- Normalized data is necessary
Head-to-Head Comparison: Real Scenarios
Scenario 1: Simple Counter
// Context: 15 lines
const CounterContext = React.createContext(0);
function App() {
const [count, setCount] = useState(0);
return (
<CounterContext.Provider value={{ count, setCount }}>
<Counter />
</CounterContext.Provider>
);
}
// Zustand: 8 lines
const useCounter = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// Redux: 60+ lines
// Winner: Context/Zustand
Scenario 2: Real-Time Dashboard
Requirements:
- 200+ components
- 30+ data sources
- Real-time updates (10+ per second)
- Derived/filtered views of data
- Time-travel debugging needed
Context: Complete failure (re-render storm)
Zustand: Works but requires discipline on selectors, async patterns inconsistent
Redux: Handles perfectly, DevTools essential for debugging
Scenario 3: E-commerce Checkout
Requirements:
- Complex state (cart, user, shipping, payment, etc.)
- Lots of validations
- Async calls for everything
- Multiple integrations
- Team of 15 people
Context: Impossible to coordinate
Zustand: Works but async patterns will diverge across team
Redux: Enforces consistency, middleware handles all integrations
Scenario 4: Prototype/MVP
Requirements:
- Ship in 2 weeks
- Small team (3 people)
- Theme toggle, auth, minimal state
Context: Fast, no setup
Zustand: Faster, scales if product works
Redux: Overkill, slow to ship
Performance Deep Dive
Context Re-render Issue (Numbers)
App with 500 components
Updating one data: User email
Context API:
- If context splits by concern: ~100 re-renders
- If unified: ~300 re-renders
- Time: 85-150ms
Zustand (proper selectors):
- Re-renders: ~5-10 (only subscribed components)
- Time: 2-5ms
Redux (with reselect):
- Re-renders: ~5-10
- Time: 2-5ms
Real Impact: This was a 10x performance difference in real production dashboards I've worked on.
Developer Experience & Learning Curve
Context
- Learning: 1 day
- Productive: 2 days
- Reality: Hits wall at medium complexity (1-2 weeks)
Zustand
- Learning: 3 days
- Productive: 5 days
- Reality: Scales to medium complexity smoothly
Redux
- Learning: 1 week
- Productive: 3 weeks
- Reality: Complexity hidden by structure, scales very far
The Real Decision Matrix
Choose CONTEXT when:
- Single-person project
- Learning React fundamentals
- Simple configuration/theme state
- Performance is NOT critical
- Just teaching, not shipping
Choose ZUSTAND when:
- Small-to-medium team (3-15 people)
- Moderate complexity
- Want something "just right"
- TypeScript matters
- Performance important but not critical
- Need flexibility
Choose REDUX when:
- Large team (10+ people)
- Complex application logic
- Async operations are core
- DevTools debugging is important
- Historical action tracking needed
- Normalizing data is necessary
- Performance is critical
- Long-term maintenance matters
Anti-Patterns I've Seen
Context Anti-Pattern: Global Context for Everything
// Common anti-pattern: One context with entire app state
const AppContext = React.createContext({
user: {},
theme: 'light',
notifications: [],
filters: {},
modal: {},
// ... 30 more things
});
// Result: Every change floods entire app with re-renders
// Solution: Split by update frequency (but defeats the purpose)
Zustand Anti-Pattern: Store Soup
// Multiple Zustand stores with cross-store dependencies
const useUserStore = create(...);
const useFilterStore = create(...);
const useModalStore = create(...);
// In component:
const user = useUserStore();
const filters = useFilterStore();
const modal = useModalStore();
// Now updates are scattered across stores
// No single source of truth
// Debugging nightmare
Redux Anti-Pattern: God Reducer
// One massive reducer doing everything
const rootReducer = (state, action) => {
// 5000 lines of switch/case
// Impossible to maintain
}
Recommendations from Experience
If You're at a Startup
Use Zustand. I've seen startups ship products in weeks with it and then scale smoothly. You get simplicity when you need it and room to grow when success hits.
If You're at a Scaleup (50-500 people)
Use Zustand until you hit a wall, then plan the migration to Redux. This is your window. Build with Zustand, but document your state patterns from day one.
If You're at a Larger Organization
Consider splitting: Context for truly local state (modals, UI toggles), Zustand for features, Redux for platform-wide concerns if needed. But honestly, pick one and stick with it.
If You're on a Large Team
Redux (or a similar enforced pattern). Individual freedom causes integration chaos every time. I've seen it happen at various scales. The structure matters more than saving a few lines of code.
If You're Building an App That'll Live 5+ Years
Redux, but do it right. The initial investment pays dividends when you're debugging why something broke at 2 AM in year 3 and Redux DevTools shows you exactly what happened.
If You're Teaching or Mentoring
Start with Context to explain the fundamentals, then show Zustand as the practical choice, and mention Redux as the enterprise standard. Don't let them learn Redux first because it obscures the core concepts.
If You're Joining an Existing Team
Use what they're using. Consistency matters more than optimal choices. The best tool is the one your team already knows.
Migration Paths
Context → Zustand (Easy)
// Mostly 1:1 replacement
// Context Provider → Zustand create()
// useContext → useStore
// 2-3 days for medium app
Zustand → Redux (Painful)
// Requires restructuring everything
// Actions → Action types
// Store functions → Reducers
// Async handling changes
// 2-3 weeks for medium app
// Potential bugs in process
Redux → Zustand (Possible but risky)
// Most Redux patterns don't translate
// You lose DevTools power
// Not recommended for production apps
Final Verdict
The Honest Truth
Context API is a footgun masquerading as a feature. I've seen so many teams start with it and regret it by month two. Use it for theme switching and that's it.
Zustand is the Goldilocks solution for most teams. It's what I reach for when starting new projects. If unsure, start here. You'll move fast, and if you outgrow it, you have room to restructure.
Redux is worth every line of boilerplate if your team commits to using it properly. I've seen it save teams countless debugging hours once they got past the learning curve. The structure it enforces becomes a feature, not a bug.
The lesson I learned the hard way: prematurely optimizing for "simplicity" leads to painful refactoring later. The best choice isn't the one that sounds easiest at day one. It's the one that scales with your team and keeps working as your application grows more complex.
Start small. But think ahead.
References
- Redux Essentials: redux.js.org
- Zustand Docs: zustand-demo.vercel.app
- React Context Gotchas: Various team retrospectives
- Real-world performance data: Production app profiling across multiple teams
Top comments (1)
Really like how thoughtfully you structured this. You addressed all the key questions that might come up during this decision-making process, and the reasoning throughout was clear and well explained.