DEV Community

Cover image for How We Managed State Across 900+ React Components Without Losing Our Minds
Keshav Agarwal
Keshav Agarwal

Posted on

How We Managed State Across 900+ React Components Without Losing Our Minds

The Bug That Changed Everything

A few months into building our enterprise SaaS platform, a customer reported a bizarre issue: saving a reward configuration would silently overwrite pricing data that another team member had edited seconds earlier.

The root cause? Two components — three levels apart in the tree — were each managing their own copy of the same entity. One saved stale data over fresh data. No error. No warning. Just silent data loss.

That incident killed any remaining appetite for scattered local state. This happened in a codebase with 900+ components across 18+ pages, multiple devs contributing simultaneously, multi-step creation flows with cross-step validation, and millions of end users.

We went all-in on centralized state management with Redux — and it became the single best architectural decision we made. (Also the most verbose one, but we'll get to that.)

Redux wasn't just our data store. It was the coordination layer — the air traffic control for components that otherwise had no business knowing about each other. A setting change in Component A causing Component B to refresh, a sidebar selection updating a detail panel three levels away — all orchestrated through Redux actions and sagas instead of prop callbacks or tangled useEffect chains.


The Architecture: Feature-Based Redux Modules

Every feature owns a self-contained Redux module with five files:

src/features/CreateEditEntity/
  constants.js    # Namespaced action types
  actions.js      # Action creator functions
  reducer.js      # State transitions (Immutable.js)
  saga.js         # Async side effects (Redux-Saga)
  selector.js     # Memoized state selectors (Reselect)
Enter fullscreen mode Exit fullscreen mode

Yes, five files per feature. I know, I know — I can hear you groaning. I'll address the boilerplate elephant later.

But this structure meant any developer could find the business logic for any feature in seconds. No "where does this state live?" Slack messages at 3 PM. Each slice owned a single concern: createEditEntity handled only the reward creation flow, configSettings handled only category config. No god-reducers trying to manage the entire known universe.

The Stack

Library Role
react ^18.2.0 UI library
redux 4.0.1 Core state container
react-redux 5.1.0 React bindings (connect, Provider)
redux-saga 0.16.2 Generator-based side effects
immutable 4.3.7 Persistent immutable data structures
reselect 4.0.0 Memoized selectors
webpack ^5.91.0 Bundler with Module Federation

Not the latest versions — and that's part of the story. This architecture was established years ago and has stayed stable through multiple product evolutions. If it ain't broke, don't npm install.

Dynamic Injection: Load Only What You Need

Not every feature loads on the first page. Reducers and sagas are injected into the store only when their component mounts:

const withReducer = injectReducer({ key: 'createEditEntity', reducer });
const withSaga = injectSaga({ key: 'createEditEntity', saga });

export default compose(withReducer, withSaga, withConnect)(CreateEditEntity);
Enter fullscreen mode Exit fullscreen mode

A user who only visits a listing page never downloads the reducer logic for the creation flow. Webpack decides when code loads - Redux decides how it integrates into the store.


The Three Patterns That Actually Mattered

Out of everything we built, three patterns delivered outsized value. If you take nothing else from this post, take these. (And maybe a coffee. This is the meaty part.)

1. Sagas as the Business Logic Layer (The Biggest Win)

This is the hill I'll die on. Sagas absorbed the business logic that would otherwise pollute components. Components became pure render functions — they dispatch intents and display state. Everything messy? That's the saga's problem now.

  • Navigation — after a successful save, the saga dispatches a route change - the component never calls history.push
  • Notifications — success/error toasts are triggered by sagas, not component-level useEffect chains
  • Cross-feature coordination — when a reward is saved, a saga dispatches an action that marks the rewards list cache as stale, so the next visit triggers a refresh
  • Dependent actions — after saving entity A, dispatch actions to refresh entity B's list, invalidate a cache slice, and update a sidebar count — without the originating component knowing about these dependencies

To make this concrete, here's our actual reward creation flow — a 5-step wizard where the saga orchestrates everything end-to-end. No code wall this time, just the flow:

┌─────────────────────────────────────────────────────────────┐
│                    EDIT / DUPLICATE FLOW                    │
│                  (initializeGetByIDSaga)                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  User navigates to /edit/:entityId                          │
│         │                                                   │
│         v                                                   │
│  1. FETCH ─── call(Api.getEntityById, entityId)             │
│         │                                                   │
│         v                                                   │
│  2. EXTRACT PRICING MODE from nested configs                │
│    (buried 3 levels deep in the API response, obviously)    │
│         │                                                   │
│         v                                                   │
│  3. PARALLEL FETCH T&C ─── filter languages with T&C URLs   │
│    │         │         │    then fetch ALL in parallel      │
│    v         v         v    (no waterfall nonsense)         │
│   [en]     [fr]      [de]  ─── convert to text              │
│    │         │         │                                    │
│    └────┬────┘─────────┘                                    │
│         v                                                   │
│  4. DISPATCH per-language T&C updates to Redux              │
│         │                                                   │
│         v                                                   │
│  5. DISPATCH GET_ENTITY_BY_ID_SUCCESS                       │
│    └─> reducer calls hydrateFormState() which transforms    │
│        the flat API blob into our 5-step form state:        │
│        name, description, audience, payment, inventory,     │
│        languages, categories, restrictions...               │
│                                                             │
│  ✓ Form is now pre-filled. User edits away.                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

That handles loading and populating the form. Now here's what happens when the user clicks Save — and this is where it gets interesting, because the saga has to gather state from 6 different Redux slices and assemble a single API payload:

┌─────────────────────────────────────────────────────────────┐
│                       SAVE FLOW                             │
│                    (saveEntitySaga)                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  User clicks "Save" ─── dispatches SAVE_ENTITY_REQUEST      │
│         │                                                   │
│         v                                                   │
│  1. GATHER STATE from 6 Redux slices:                       │
│    ┌────────────────────────────────────────────┐           │
│    │  orgData ─── tenantData ─── configData.    │           │
│    │  formState ─── pricingMode ─── customFields│           │ 
│    └────────────────────────────────────────────┘           │
│         │                                                   │
│         v                                                   │
│  2. TRANSFORM via generateSavePayload()                     │
│    UI state ──────────────────────────> API payload         │
│    ┌──────────────────────────────────────────┐             │
│    │  Step 0: Basic meta (dates → UTC)        │             │
│    │  Step 1: Targeting (segments, tiers)     │             │
│    │  Step 2: Pricing & billing configs       │             │
│    │  Step 3: Limits (per-user, global)       │             │
│    │  Step 4: Localization (multi-lang)       │             │
│    │  Step 5: Additional (tags, groups, cats) │             │
│    └──────────────────────────────────────────┘             │
│         │                                                   │
│         v                                                   │
│  3. DETECT MODE ─── CREATE or EDIT?                         │
│         │                                                   │
│         v                                                   │
│  4. API CALL ─── Api.createEntity or Api.editEntity         │
│         │                                                   │
│         ├──── success ──> dispatch SUCCESS                  │
│         │                 push analytics event              │
│         │                 navigate to /listing              │
│         │                 show toast ("Go grab a coffee")   │
│         │                                                   │
│         └──── failure ──> dispatch FAILURE                  │
│                           show error toast                  │
│                           (we blame the API, naturally)     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The beauty of this? The component does almost nothing interesting.

It renders form fields across 5 steps, dispatches SET_* actions on keystrokes (SET_NAME, SET_DESCRIPTION, SET_TARGET_SEGMENT, SET_LIMITS...), and dispatches SAVE_ENTITY_REQUEST when the user clicks Save. That's it.

The component doesn't know how to transform data, doesn't know which API to call, doesn't know about T&C URL fetching, doesn't care about analytics. All that orchestration — loading, transforming, assembling across 6 reducers, saving, tracking, navigating — lives in the sagas. The component just renders and dispatches. No useEffect chains. No "which lifecycle method does this run in?" existential crises.

Any time you find yourself writing useEffect(() => { if (saveSuccess) { navigate(...); showToast(...); trackEvent(...); } }), that logic belongs in a saga. The saga owns the entire chain of consequences from a single user intent.

2. Concurrent Uploads with eventChannel + fork

This pattern handled hundreds of concurrent media uploads in production. We needed bulk uploads of images, videos, and rich content to S3 — each tracked independently with progress, error state, and retry capability.

function createUploadChannel(file) {
  return eventChannel((emitter) => {
    const xhr = new XMLHttpRequest();
    xhr.upload.onprogress = (e) =>
      emitter({ progress: Math.round((e.loaded / e.total) * 100) });
    xhr.onload = () => { emitter({ success: true }); emitter(END); };
    xhr.onerror = () => { emitter({ error: true }); emitter(END); };
    xhr.open('POST', '/api/upload');
    xhr.send(file);
    return () => xhr.abort(); // cleanup = cancellation
  });
}

function* uploadSingleFile(file, index) {
  const channel = yield call(createUploadChannel, file);
  try {
    while (true) {
      const event = yield take(channel);
      if (event.progress)
        yield put({ type: 'UPDATE_PROGRESS', index, progress: event.progress });
      if (event.success)
        yield put({ type: 'FILE_UPLOAD_SUCCESS', index });
    }
  } finally {
    channel.close();
  }
}

function* uploadAllFiles({ payload: files }) {
  const tasks = yield all(
    files.map((file, i) => fork(uploadSingleFile, file, i))
  );
  // Wait for all uploads OR a cancel action — whichever comes first
  yield race({
    done: all(tasks.map((t) => t.toPromise())),
    cancel: take('CANCEL_ALL_UPLOADS'),
  });
}
Enter fullscreen mode Exit fullscreen mode

eventChannel bridges callback-based APIs into the saga world. fork spawns concurrent tasks. race lets the user cancel everything mid-flight. Try building this with useState and useEffect — I'll wait. (Actually, don't. You'll end up with a ref-heavy, leak-prone mess and a therapy bill.)

3. No Prop Drilling — Components Connect Directly

This is what makes Redux scale in component-heavy apps. In our codebase, components at every level connect directly to the store:

  • Parent page connects to 4 selectors
  • Child form independently connects to 9
  • Sibling accordion connects to 13
  • Deeply nested molecule (3 levels deep) connects to 5 selectors spanning multiple reducer domains

None of these pass data to each other through props. Each component declares exactly which state slices it needs. Adding a new data dependency to the nested molecule requires zero changes to any parent component.

We used createStructuredSelector + factory selectors (makeSelect*) to ensure each connected component gets its own memoization cache:

const mapStateToProps = createStructuredSelector({
  entityDetails: makeSelectEntityDetails(),
  isLoading: makeSelectIsLoading(),
});

const withConnect = connect(mapStateToProps, mapDispatchToProps);
export default compose(withReducer, withSaga, withConnect)(EntityComponent);
Enter fullscreen mode Exit fullscreen mode

What Bit Us (And What We'd Do Differently)

Look, no architecture survives contact with reality unscathed. Here's where ours drew blood — and how we'd fix each one.

Immutable.js Was a Mistake → Use Immer

I'll say it plainly. Every boundary in our code has a tax:

  • Reading a value? state.get('name') instead of state.name — because apparently dot notation was too easy
  • Passing to a child component? .toJS() — which creates a new object reference every render, defeating the very memoization you set up five minutes ago
  • Receiving from an API? fromJS(response) — easy to forget, leading to subtle bugs where a component gets an Immutable Map instead of a plain object and React just... renders [object Object] with a straight face

New developers would spend their first week debugging issues that boiled down to "you forgot .toJS()" or "you called .get() on a plain object." The cognitive overhead never went away. It's like a toll booth on every data highway in the app.

The fix: RTK uses Immer under the hood. Write state.name = 'foo' and get immutable updates for free. No more .get() / .set() / .toJS() gymnastics. This is the change we want most.

Five Files Per Feature Is a Lot → RTK createSlice

Every new feature: constants, actions, reducer, saga, selector. For a simple "show advanced options" toggle, a junior developer has to touch all five files plus wire up injectReducer. That's the kind of ceremony that makes people question their career choices.

The fix: createSlice collapses constants + actions + reducer into one file. One file per feature instead of five — that alone would cut our boilerplate by 60%.

And here's the twist: AI-driven development has largely neutralized this pain in the meantime. Tools like Cursor and Claude can generate a complete feature module from a single prompt. The repetitive, predictable patterns that make Redux verbose are precisely what make AI assistance highly effective. What used to be 30 minutes of boilerplate is now 2 minutes of AI-generated code plus a quick review.

Sagas Are Hard to Debug → Keep for Complex Flows, RTK Query for the Rest

Stack traces through generator functions are opaque. The declarative effect model (call, put, select) means you can't just set a breakpoint and step through — you have to understand the saga middleware's execution model. We had a saga that silently swallowed errors for weeks because a try/catch was in the wrong place. Finding it required manually stepping through the generator yields like an archaeologist brushing dirt off pottery.

The fix: RTK Query for straightforward data fetching — it handles loading states, caching, and cache invalidation out of the box. Keep sagas only for complex orchestration flows where they genuinely shine (like the reward creation wizard above).

connect() HOCs Feel Dated → Hooks

Our codebase uses connect() rather than useSelector/useDispatch hooks. This leads to deeply nested component trees in React DevTools — it's HOCs all the way down. Functional, battle-tested — but it has that "we built this before hooks existed" energy.

The fix: useSelector and useDispatch are simpler, more composable, and produce cleaner component code. No more HOC wrapper hell in React DevTools.

The silver lining: AI-assisted migration

We're not stuck with these pain points forever. With tools like Claude and Cursor, migrating away from Immutable.js, converting HOCs to hooks, and collapsing five-file modules into RTK slices isn't a quarter-long rewrite — it's a series of well-prompted afternoons. The same repetitive patterns that make Redux verbose make it a perfect target for AI-assisted refactoring. We're chipping away at the tech debt one feature at a time, and it's going faster than anyone expected.

The core architecture stays. Feature-based modules with a centralized store remain the right choice at this scale. The organizational pattern scales - only the implementation details change.


The Takeaway

Redux gets a bad reputation for boilerplate, and honestly? Some of that is deserved. Writing five files to add a boolean toggle does feel like filling out government paperwork.

But for a complex, multi-team enterprise application where state consistency and debuggability matter more than velocity on small features, centralized Redux gave us something invaluable: predictability at scale.

900+ components. 18+ pages. Millions of users. Multiple devs. Quarters of active development. Zero "where does this state live?" mysteries. That's a trade-off we'd make again.

Pick your tools based on your complexity:

  • For local UI state, useState is perfect — don't let anyone shame you into Redux for a modal toggle.
  • For server state caching, RTK Query or TanStack Query are excellent.
  • But when your app becomes a coordination problem — when components across different routes need to react to each other's changes — a centralized store with a proper orchestration layer is hard to beat.

Have you migrated a large Redux codebase to RTK, swapped Immutable.js for Immer, or converted connect() HOCs to hooks? What broke first? Drop your war stories below.


Shoutout to Anurag Volety for the early guidance and mentorship that shaped how I think about building UI at scale.

Top comments (0)