My approach to React app architecture in 2025
Published August 27th, 2025
TL;DR
This is a bit of a deep dive into my current philosophy of React application development in 2025. The main takeaway is that a lot of component complexity can be mitigated by following these two patterns:
-
Lift content up – if a component doesn’t influence how a child component is rendered, but just renders it in a given slot, the act of rendering that child component could be moved up to this component’s parent.
-
Push state down – the points where “global” state is “consumed” by components (think Redux/React Query/React Context) should be pushed down as close as possible to the components that actually render UI based on that state, rather than consuming the global state in an ancestor component and passing down an aspect of the state multiple levels in the component tree.
Without further ado…
Intro
At its core, using React to build user interfaces is quite simple. It produces UI as a function of some state:
Describe what UI you want with JSX (createElement()
calls visualized as HTML), as well as how your state should influence what UI to show, using plain JavaScript logic, and React itself takes care of making it happen (and re-happen).
Consider this “component” that displays the current weather:
When this component “renders” (the function is executed and returns a description of what UI should be shown), most of it is plain text, but there are two pieces of variable data: a date, and the status
of the weather.
At the time the component renders, that new Date().toString()
expression is evaluated and produces a datetime string, props.status
is evaluated to either be 'sunny'
or 'raining'
, and the component (essentially) returns a complete string similar to Today is January 1st, 2025, and it is currently sunny outside.
It’s beautiful, it’s simple, it’s declarative. No wonder everyone uses React! Until…
<cautionary-tale>
Adding a harmless little prop
This component is useful, but let’s say I’m building a large weather app, and that this component will be used in many different places and in different contexts. In some places, I may want this text to be displayed as a plain string (like above), and in some places, I may want the date and the status
to be bold/highlighted. We could enable these different “variants” of this component by adding more props like this:
Ok great! Now if I render my component like this <CurrentWeather status="sunny" variant="highlighted" />
(components can be treated as JSX too), I get the date and status in bold text. Just wait, things are about to get a lot less great!
Adding completely different behavior
Another team wants to use this component, and they DON’T want bold text using a <strong>
element; they want the date string to show an ad for watches in a popover when you hover over it (PMs ask for weird stuff sometimes 🤷♂️). So, to enable this behavior, we update the component like this:
Ok, a few nice things have happened, and a few very NOT nice things have happened, including:
-
First, we’re taking advantage that components are just plain JavaScript functions and pushing the logic for what to show for the
date
andstatus
up into the body of the function, rather than what would have been quite large and confusing ternary expressions. This is a GOOD development! -
Now
CurrentWeather
is tightly coupled toWatchUpsellPopover
. It is fully responsible for making THAT work, when all it did before was render the date and the current weather. This is NOT a good development.
Adding even more completely different behavior
But now that WatchUpsellPopover
is making us so much money that ANOTHER team wants to use it too, but they want to sell some kind of weather tablet that just happens to also tell time (PMs, man. I’m tellin’ ya… 🙄). So now they’ve swooped into our component and updated it some more:
The wheels start falling off
Can you spot the bug they added…I’ll wait……….seriously, look closely……found it, yet?
…
…
…
Ok, so suddenly, everywhere we show the <CurrentWeather />
, we’re trying to sell people tablets! That new includeTabletUpsell
has a default value of true
and because it’s the first if
statement, we now always render the tablet upsell component, regardless of whatever other props we pass in.
Actually there’s ANOTHER potential bug, which is that now CurrentWeather
needs to be rendered within a CurrentUserContext
so that we can pass a userName
to TabletUpsellPopover
. This means that, in any instances where CurrentWeather
was previously rendered outside of the CurrentUserContext
, we’d get another error, even if we didn’t care at all about using that tablet upsell thing!
Incidents are declared, postmortems are had, feelings are hurt. Some executive decides to make their career by “spearheading an initiative” to refactor these upsell components so that they can’t step on each other’s toes anymore:
The exec gets promoted, this bug gets fixed, the code is arguably better than it was before, everything’s good…until the next team decides to use it…
</cautionary-tale>
How did we get here?
What started out as a simple component that rendered a string ended up growing and growing and eventually causing bugs. There’s no single actor to blame in this story; everyone was just trying to do their job and to make the smallest, most sensible change at the time, but without a mutually agreed upon set of patterns and principles for how to write components, things can get out of control quickly. Some basic critiques I have are:
The props
are confusing
Our component now has a variant
prop and an upsellKind
prop. This component’s name and its external API (the props
it takes in) doesn’t give you any clue how these props are used or how they work together or how they can conflict. I’m forced to internalize the component’s implementation to really understand what’s happening and how to use it.
No access to child components
If I want highlighted text, but to not use the <strong>
element, I’d have to make yet another confusing change to this component’s logic to swap it out in some way, like we do for the UpsellPopover
.
Tightly-coupled components
Any change to UpsellPopover
, like to the kind
prop it supports, now also requires a change to CurrentWeather
. Their APIs need to stay in sync in order for CurrentWeather
to work properly. If UpsellPopover
were to get widely and deeply adopted in the same way across the organization, a simple change to its API now requires a lot of refactoring.
What I’d do differently
Having spent over 4 years building and using React components at LD, I’ve seen this kind of organic bloat happen over and over again. Sometimes it’s code that one person wrote for a specific purpose that gets picked up and adapted to a totally different context, sometimes it involves not knowing where to look to find the most relevant pattern for what you’re trying to do. Sometimes it’s simply a good idea that gradually turns into a bad idea.
But our application is complex and so is the UI that supports it, so are large components with some amount of error-prone complexity just unavoidable, despite our best intentions? Sometimes, yes, but here are some patterns and principles I’ve gradually settled on that can really help both minimize and manage complexity in React apps:
1. Lift content up
CurrentWeather
is responsible for A LOT of logic to decide what to render in the {date}
slot. This logic will only ever grow as we decide to render that date
in more and different ways. A more maintainable approach would be to “lift up” the actual rendering of the date
to be outside CurrentWeather
and to pass the rendered content back in as a date
prop. If we do that, then each team can easily decide for themselves what kind of date they want to render, and they completely own and control what props are needed to render that date:
Here, we’re relinquishing control of WHAT to render for the date
and status
and focusing on HOW to render them and in what layout. After that, each team writes a small wrapper component that composes CurrentWeather
together with WHAT they want to render for the date
and status
.
But now each wrapper component evaluates new Date().toString()
, which is a lot of duplication that could lead to inconsistencies across the app. If we want to regain control JUST over how the current date string is created, we can do that like this:
We type the date
prop as (dateString: string) => ReactNode
, expecting consumers to provide a function that we can call with the date string that WE control and that returns a ReactNode
. We still don’t have control over what that ReactNode
actually is, but we’ve standardized the format of the dateString
, and, apart from that, we’re trusting our teams to render something sensible.
2. Push state down
Remember that other potential bug about the current user context? Let’s revisit that wrapper that the Tablet Team may have made. Given that they needed that CurrentUserContext
, they’d end up writing this:
They’re free to use TabletUpsellPopover
directly again, as this wrapper component is clearly meant for THEIR feature. So they’ve added that hook to use
the CurrentUserContext
. But should this logic REALLY be here? It only serves to pass on the currentUserName
to the subcomponent. Given this, PLUS the fact that we’re assuming that CurrentUserContext
is “global state” in some sense (meaning it’s provided at the root of the application in some way), there’s no point in consuming that context here. It would best be pushed down closer to the component that needs it:
A final example: a Task
component
Consider this component, which renders a single task in a task list:
That’s rendered within a TaskList
component:
At face value, this seems quite reasonable! But multiply the complexity X50, and we get some of the huge components and state management hooks we currently deal with on the frontend in gonfalon
.
Consider this, in contrast:
But y, tho?
This may seem a bit nuts, in comparison! Dare I say: over-engineered
! But fast forward 1, 2, 3 years. What’s changed? Maybe we’ve added more elements to a Task
, maybe we’ve refactored how we fetch, cache, and update tasks in the backend (the useTask
hook). Maybe we’re feature flagging a refactor to the look and feel of the completed checkbox.
In any case, we’ve distilled the architecture down to its core units. Each component/hook does one thing and can be easily swapped/discarded accordingly. The APIs we’ve chosen to define, and the boundaries we’ve chosen to draw, let each piece of the puzzle be blissfully unaware of the other.
By lifting content up and pushing state down as much as we can, we can flatten the number of layers that separate the UI we show from the things they do.
I’ll have more to say later as it relates to specific implications or recommendations for how we could better leverage these patterns going forward.