Hasan's Journal

Stories, lessons, and scars from production.

Mehedi Hasan
Back to blog

Scaling a React ERP: Lessons from Real Production

What 150+ enterprise dashboards taught me about state management, performance budgets, and the cost of the abstraction I shouldn't have built.

#React#Performance#Architecture#ERP

The Starting Point

Six years into the garments ERP, the system had grown to more than 150 dashboards, many sharing overlapping slices of the same data. Building dashboards for a factory floor means designing for users under time pressure, working with data messier than any seed dataset, and accepting that users will discover every edge case you didn't test — a partial shipment, a returned batch, an employee who belongs to two departments at once. The stakes are higher than they appear: a miscalculated inventory count or a dropped production order translates directly into lost money, not just an annoyed user. The cost of a bug in a consumer app is a user churning; the cost of a bug in a factory ERP is a shipment being delayed or a payroll run being wrong.

Enterprise software also taught me to respect the slow pace at which real organizations change. A new feature that requires users to learn a new workflow is a disruption, and disruptions have a real cost in productivity that has to be earned back over weeks of use. The features that succeeded were the ones that fit into existing workflows and made them faster; the features that failed were the ones that asked users to change how they worked, even when the new way was objectively better. That bias toward incremental improvement over transformation is the single biggest difference between consumer and enterprise software engineering.

State Management at Scale

The state management strategy became make-or-break at 150 dashboards. We started with local component state, graduated to a fully centralized Redux store (which solved sharing but introduced boilerplate and an unwieldy store mixing server state with client state), and eventually settled on a hybrid: Redux Toolkit for genuinely global client state (current user, active tenant, UI preferences), React Query for anything from the server, and URL search params for filter and sort state. The key insight was that not all state belongs in Redux — server state has different lifecycle needs (caching, background refetching, invalidation) that a dedicated data-fetching library handles more cleanly than hand-rolled reducers.

Moving server data out of Redux and into React Query reduced our Redux store size by roughly 70% and eliminated an entire category of bugs where cached server data had gone stale. The URL search params layer is the one most teams forget, and it pays the largest dividends: when filter and sort state live in URL params, they survive refresh, can be shared as links, work with the back button, and make the application's state observable from the address bar. A user reporting "the dashboard is showing the wrong data" can share the URL and you immediately see their filter state.

The Abstraction That Cost Me a Year

My biggest regret is the generic dashboard engine I built in year two. On paper it was elegant: a configuration-driven component that could render any dashboard from a schema. In practice, every new dashboard needed custom behavior that the engine's abstraction leaked constantly, and debugging meant tracing through layers of indirection. We added escape hatches — custom render functions, conditional widget types — and each made the engine harder to reason about. More than half the dashboards using it were using at least one escape hatch, paying the cognitive cost of the abstraction without getting most of its benefits. It cost roughly a year of accumulated friction before we retired it.

Since then I've adopted: write the third version, not the first. Build the first version quickly and specifically for the problem in front of you. The second version, for a slightly different but related problem, reveals which parts are genuinely shared. Only then extract the abstraction. The cost of the first version being slightly duplicative is almost always lower than the cost of an abstraction that doesn't fit, because duplication can be cleaned up later but a wrong abstraction resists cleanup and bends every new requirement out of shape. This is the single most important lesson about abstraction I've learned.