Beyond the UI: Understanding Modern Frontend Engineering · Part 6
New to this series? Start with Part 1
State Management Explained: Why Frontend State Gets Complicated
“State management becomes difficult not because applications have state, but because different kinds of state have different owners, lifetimes, and sources of truth.”
Imagine you’re building a simple product page.
At first, there isn’t much to manage.
1
const [quantity, setQuantity] = useState(1);
Easy.
Then you add a product variant.
1
const [selectedColor, setSelectedColor] = useState("black");
Then a modal.
1
const [isModalOpen, setIsModalOpen] = useState(false);
Then authentication, the shopping cart, filters, pagination, API responses, loading states, and errors. The URL needs to remember the filters, another component needs access to the cart, and when the user navigates away and comes back, some state should remain while other state should disappear.
Before long, your application looks like this:
1
2
3
4
5
6
7
8
9
10
Frontend Application
│
├── UI State
├── Form State
├── Server State
├── URL State
├── Authentication
├── Cached Data
├── Global State
└── Derived State
And suddenly someone says:
“We need a state management library.”
But that’s often jumping ahead. Before choosing Redux, Zustand, Pinia, Context, TanStack Query, signals, or anything else, there’s a more important question:
What kind of state are we actually trying to manage?
Not everything called “state” is the same problem, and once you understand that, frontend state management becomes much easier to reason about.
What Is State?
At its simplest, state is information that can change over time and affects what your application does or displays.
Consider a counter:
1
const [count, setCount] = useState(0);
The UI depends on count.
1
2
3
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
At one moment:
1
count = 0
After a click:
1
count = 1
The interface changes because the underlying state changed.
We can think of the UI as a function of state:
1
2
3
4
5
6
7
State
│
▼
Render
│
▼
UI
Change the state:
1
2
3
4
5
6
7
New State
│
▼
Re-render
│
▼
New UI
This state-driven model is fundamental to frameworks such as React, Vue, Svelte, and others. The problem isn’t the idea itself. The problem begins when an application has many different sources of changing information.
A Small Application Doesn’t Need “State Management”
Consider a dropdown.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Dropdown() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>
Menu
</button>
{open && (
<div>
Profile
Settings
Logout
</div>
)}
</div>
);
}
The state belongs naturally to the dropdown.
1
2
3
Dropdown
│
└── open
Nobody else needs it.
When the dropdown disappears, we probably don’t care about preserving that state.
This is local component state.
And local state is often the best kind of state.
The closer state lives to the code that actually needs it, the easier the application is usually to understand.
The trouble starts when state begins travelling.
When State Needs to Be Shared
Imagine two components:
1
2
3
4
5
6
7
Header
│
└── CartIcon
ProductPage
│
└── AddToCartButton
When the user clicks:
1
Add to Cart
the header needs to update:
1
2
3
4
Cart (0)
│
▼
Cart (1)
Now the cart state cannot live exclusively inside AddToCartButton. Both components need access to it. A common solution is to move the state upward.
1
2
3
4
5
6
7
8
App
│
cartItems
/ \
▼ ▼
Header ProductPage
│ │
CartIcon AddToCart
This is commonly called lifting state up: move shared state to a common owner and pass the necessary values or update functions downward. React’s documentation recommends this pattern when multiple components need coordinated state. (React)
That works well.
Until the component tree gets larger.
Prop Drilling Appears
Suppose the structure becomes:
1
2
3
4
5
6
7
8
9
10
App
│
├── Header
│ └── Navigation
│ └── CartButton
│
└── Main
└── ProductPage
└── ProductDetails
└── AddToCartButton
The cart state lives in App.
But AddToCartButton needs it.
You may end up passing:
1
2
3
4
5
6
7
8
9
<App>
<Main cart={cart}>
<ProductPage cart={cart}>
<ProductDetails cart={cart}>
<AddToCartButton cart={cart} />
</ProductDetails>
</ProductPage>
</Main>
</App>
Main doesn’t care about the cart, and ProductPage may not care either. They’re simply transporting information.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
App
│
│ cart
▼
Main
│
│ cart
▼
ProductPage
│
│ cart
▼
ProductDetails
│
│ cart
▼
AddToCartButton
This is commonly called prop drilling, and it’s often the moment developers begin looking for global state management. But before we reach for a global store, we need to make another distinction.
Not All State Is Application State
Consider this dashboard:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────────────────────────────┐
│ Dashboard │
│ │
│ Search: [ laptop ] │
│ │
│ Status: Active │
│ │
│ Customers │
│ ┌─────────────────────────────────┐ │
│ │ Alice │ │
│ │ Billy │ │
│ │ James │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
What state exists here?
Potentially:
1
2
3
4
5
6
7
8
searchText
selectedStatus
customers
isLoading
error
currentUser
sidebarOpen
currentPage
It’s tempting to put all of that into:
1
Global Store
But those values represent very different things.
For example:
1
sidebarOpen
belongs to the UI.
While:
1
customers
probably came from a server.
And:
1
currentPage
might belong in the URL.
Treating them identically creates unnecessary complexity.
A useful state architecture starts by classifying state.
1. Local UI State
Local UI state represents temporary interaction details.
Examples include:
1
2
3
4
5
6
Modal open?
Dropdown expanded?
Selected tab?
Hovered item?
Accordion expanded?
Tooltip visible?
For example:
1
const [isOpen, setIsOpen] = useState(false);
This state is usually:
- Temporary
- Owned by one component or a small subtree
- Not important outside that UI
- Safe to discard when the component disappears
It should usually stay local.
1
2
3
Modal
│
└── isOpen
Moving every boolean into a global store:
1
2
3
4
store.modalOpen
store.dropdownOpen
store.tooltipVisible
store.settingsTab
can turn a simple application into a state-management bureaucracy.
2. Shared Client State
Some state genuinely needs to be shared across different parts of the application.
Examples might include:
1
2
3
4
5
Shopping cart
Application theme
Feature preferences
Complex editor state
Currently selected workspace
Imagine:
1
2
3
4
5
6
Application
│
Cart State
/ | \
▼ ▼ ▼
Header Product Checkout
This is where mechanisms such as:
1
2
3
4
5
Context
Redux
Zustand
Pinia
Signals
can become useful depending on the framework and complexity.
The important point is:
Global state should be global because its ownership is global, not because passing props became mildly inconvenient.
3. Server State
Now consider:
1
const [products, setProducts] = useState([]);
At first glance, this looks like ordinary state.
But where did those products come from?
Probably:
1
2
3
4
5
6
7
8
9
10
Database
│
▼
Backend
│
▼
API
│
▼
Frontend
The frontend does not actually own this data. The server does, and the browser has a local copy. That creates completely different problems.
Suppose you fetch:
1
GET /api/products
and receive:
1
2
3
4
5
6
7
[
{
"id": 1,
"name": "Keyboard",
"stock": 12
}
]
Five seconds later, another customer buys two keyboards.
The server now has:
1
stock = 10
Your frontend still has:
1
stock = 12
Now your state is stale.
That’s not usually a problem with a modal boolean.
1
isModalOpen = true
doesn’t suddenly become outdated because another computer changed something.
Server state does.
Server State Has Different Problems
Managing server state means thinking about:
1
2
3
4
5
6
7
8
9
10
Fetching
Caching
Staleness
Retries
Refetching
Deduplication
Pagination
Mutations
Optimistic updates
Synchronization
Consider:
1
2
3
4
5
useEffect(() => {
fetch("/api/products")
.then(response => response.json())
.then(setProducts);
}, []);
Looks simple, until requirements arrive. We need loading state.
1
const [loading, setLoading] = useState(true);
Then errors.
1
const [error, setError] = useState(null);
Then retries, caching, refetching when the user returns to the tab, avoiding duplicate requests, and invalidating products after an update.
What looked like:
1
products
was actually a synchronization problem between:
1
2
3
4
5
6
7
Server Truth
│
▼
Client Cache
│
▼
UI
That’s why tools such as TanStack Query, SWR, Apollo Client, and framework-native data layers exist. They aren’t simply storing variables. They’re managing remote data lifecycles.
Server State Shouldn’t Automatically Live in Your Global Store
A common architecture used to look like:
1
2
3
4
5
6
7
API
│
▼
Redux
│
▼
Components
Every API response was copied into the application’s global state.
For some applications, that can still be appropriate.
But often you’re making your client store responsible for problems that a server-state cache is specifically designed to solve.
A modern architecture may instead look like:
1
2
3
4
5
6
7
8
9
10
11
Frontend
│
┌──────────┴──────────┐
│ │
▼ ▼
Client State Server State
│ │
Zustand / Query Cache
Redux / │
Context ▼
Backend API
Different problems.
Different tools.
4. URL State
Suppose your product page has filters:
1
2
3
4
Category: Laptops
Price: 20,000 - 100,000
Sort: Price Low to High
Page: 3
You could store them as:
1
2
3
const [category, setCategory] = useState("laptops");
const [sort, setSort] = useState("price");
const [page, setPage] = useState(3);
But what happens when the user refreshes? The state disappears. What happens when they copy the URL and send it to someone? The other person sees the default filters. What happens when they press the browser’s Back button? Potentially, nothing useful. Maybe this state belongs somewhere else: the URL.
1
/products?category=laptops&sort=price&page=3
Now:
1
2
3
4
5
URL
│
├── category=laptops
├── sort=price
└── page=3
becomes the source of truth.
This gives us useful behavior almost automatically:
1
2
3
4
5
Refresh ✓
Share link ✓
Bookmark ✓
Back button ✓
Forward button ✓
This is why the URL itself should be thought of as a state container.
Don’t Duplicate URL State
A common mistake is:
1
2
3
4
5
6
7
8
9
URL
│
└── page=3
AND
React State
│
└── page=3
Now you have two sources of truth.
What happens if:
1
2
3
4
5
URL page = 3
but
React page = 2
Which one wins?
You’ve created a synchronization problem that didn’t need to exist.
A better model is:
1
2
3
4
5
6
7
URL
│
▼
page
│
▼
UI
If the URL owns the value, read it from there.
5. Form State
Forms deserve their own category because they can become surprisingly complicated.
A simple form:
1
const [email, setEmail] = useState("");
is easy.
Now add:
1
2
3
4
5
6
7
Name
Email
Phone
Country
Address
Password
Confirm Password
Then:
1
2
3
4
5
6
7
8
Validation
Touched fields
Dirty fields
Submitting
Server errors
Conditional fields
Resetting
Default values
Suddenly, “email” isn’t the only state.
You have:
1
2
3
4
5
6
value
valid?
touched?
dirty?
error?
submitting?
for potentially dozens of fields.
That’s why libraries such as React Hook Form, Formik, and framework-specific form systems exist.
Again, the lesson isn’t:
Use a library for every form.
It’s:
Recognize that form state has a particular lifecycle and shouldn’t automatically become global application state.
6. Derived State
Derived state is one of the most common sources of unnecessary complexity.
Imagine:
1
2
3
const [firstName, setFirstName] = useState("Billy");
const [lastName, setLastName] = useState("Okeyo");
const [fullName, setFullName] = useState("Billy Okeyo");
We now have three pieces of state.
But do we really?
fullName can be calculated:
1
const fullName = `${firstName} ${lastName}`;
So our actual state is:
1
2
firstName
lastName
and:
1
fullName
is derived.
React’s own guidance recommends avoiding redundant state when a value can be calculated from existing props or state during rendering. (React)
Why?
Because duplication creates synchronization problems.
The Synchronization Trap
Suppose we store all three:
1
2
3
const [firstName, setFirstName] = useState("Billy");
const [lastName, setLastName] = useState("Okeyo");
const [fullName, setFullName] = useState("Billy Okeyo");
Now someone writes:
1
setFirstName("John");
but forgets:
1
setFullName("John Okeyo");
We get:
1
2
3
firstName = John
lastName = Okeyo
fullName = Billy Okeyo
Your application contradicts itself.
The problem isn’t React.
The problem is that we stored information that could have been calculated.
A good rule is:
Store the minimum information required to describe the application. Derive everything else.
React describes this as finding the minimal but complete representation of UI state. (React)
Another Derived State Example
Suppose:
1
2
const [products, setProducts] = useState([...]);
const [search, setSearch] = useState("");
Should we also store:
1
const [filteredProducts, setFilteredProducts] = useState([]);
Probably not.
We can derive it:
1
2
3
4
5
const filteredProducts = products.filter(product =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
Our model becomes:
1
2
3
4
5
6
7
products ──────┐
│
▼
Filter ───► filteredProducts
▲
│
search ─────────┘
filteredProducts is an output.
Not necessarily state.
State Gets Complicated When We Duplicate Truth
This is one of the biggest themes in frontend state management.
Imagine:
1
2
3
4
5
6
7
8
9
API Response
│
├── Redux Store
│
├── Component State
│
├── Form State
│
└── localStorage
Now the same information exists in four places.
Every update creates a question:
1
Which one is correct?
Then:
1
Which one should update first?
Then:
1
What if one update fails?
Then:
1
What happens after refresh?
A large percentage of state-management complexity is actually state synchronization complexity.
The fewer copies of truth you maintain, the fewer things you need to synchronize.
Impossible States
Poorly structured state can also represent combinations that should never exist.
Consider:
1
2
3
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
Nothing technically prevents:
1
2
3
isLoading = true
isSuccess = true
isError = true
But what does that mean?
We’re loading successfully while failing?
Instead, maybe the application actually has one state:
1
const [status, setStatus] = useState("idle");
with possible values:
1
2
3
4
idle
loading
success
error
Now:
1
status = loading
cannot simultaneously be:
1
status = success
The data structure itself prevents invalid combinations. React’s state-structure guidance specifically recommends avoiding contradictory state for this reason. (React)
This idea becomes extremely powerful in complex UIs.
Think in State Machines
Consider a payment flow.
You might start with:
1
2
3
4
const [loading, setLoading] = useState(false);
const [paid, setPaid] = useState(false);
const [failed, setFailed] = useState(false);
const [cancelled, setCancelled] = useState(false);
But conceptually, the payment probably behaves more like:
1
2
3
4
5
6
7
8
9
10
11
12
┌───────────┐
│ IDLE │
└─────┬─────┘
│ Pay
▼
┌───────────┐
│PROCESSING │
└─────┬─────┘
│
┌───────┼────────┐
▼ ▼ ▼
SUCCESS FAILED CANCELLED
Representing it as:
1
const [status, setStatus] = useState("idle");
makes the model much closer to reality.
Good state management often starts with good data modelling, not better libraries.
State Ownership Matters
Imagine three components:
1
2
3
4
ProductPage
├── ProductGallery
├── ProductDetails
└── AddToCart
Only ProductGallery needs:
1
currentImage
Where should it live?
Probably:
1
2
3
ProductGallery
│
└── currentImage
Not:
1
2
3
Global Store
│
└── currentImage
Now suppose both ProductDetails and AddToCart need:
1
selectedVariant
Then perhaps:
1
2
3
4
5
6
7
ProductPage
│
└── selectedVariant
│
┌────┴─────┐
▼ ▼
ProductDetails AddToCart
State should generally live at the lowest level that owns all consumers that need it.
Move it upward when necessary.
Not before.
State Lifetime Matters Too
Different state should survive for different amounts of time.
Consider:
1
Tooltip open
Lifetime:
1
Seconds
Shopping cart:
1
Minutes / Days
Authentication session:
1
Hours / Days
URL filter:
1
As long as URL exists
Server cache:
1
Until stale / invalidated
User preferences:
1
Months
Putting all of these into one store ignores their fundamentally different lifecycles.
A useful way to think about state is:
1
2
3
4
5
6
7
8
9
10
11
State
│
├── Who owns it?
│
├── Who needs it?
│
├── How long should it live?
│
├── Where is the source of truth?
│
└── What causes it to change?
Answer those questions first.
Then choose the storage mechanism.
Persistence Is Not the Same as State Management
Suppose we want the theme to survive refreshes.
We might use:
1
localStorage.setItem("theme", "dark");
Now some developers immediately put all application state into localStorage.
But persistence creates another source of truth.
1
2
3
4
Application State
│
▼
localStorage
What happens when the application’s expected data structure changes? What happens when the stored value is invalid? What happens when the user logs out? What happens when data becomes stale? Persistence should be intentional. Not every state deserves immortality.
Context Is Not Automatically a State Manager
In React, Context is often introduced when prop drilling becomes uncomfortable.
1
2
3
<CartContext.Provider value={cart}>
<App />
</CartContext.Provider>
Then:
1
const cart = useContext(CartContext);
This solves a distribution problem.
Instead of:
1
2
3
4
5
6
7
8
9
10
App
│ props
▼
A
│ props
▼
B
│ props
▼
C
we get:
1
2
3
4
Context
/ | \
▼ ▼ ▼
A B C
That’s useful.
But Context doesn’t automatically solve:
1
2
3
4
5
6
Caching
Server synchronization
Persistence
Complex update logic
Optimistic updates
Normalized entities
It’s a mechanism for making values available through a component tree. Sometimes that’s all you need. Sometimes it isn’t.
Why Global Stores Exist
As applications grow, some client state can become genuinely complicated.
Imagine an image editor.
1
2
3
4
5
6
7
8
9
Canvas
│
├── Selected objects
├── Layers
├── History
├── Zoom
├── Tools
├── Clipboard
└── Document state
Many distant components may need to read and modify the same information.
1
2
3
4
5
6
7
Editor State
/ | \
▼ ▼ ▼
Toolbar Canvas Layers
│ │ │
▼ ▼ ▼
Buttons Objects Panel
A centralized or shared store can provide:
1
2
3
4
5
6
One ownership model
Predictable updates
Subscriptions
Selectors
Debugging tools
Middleware
This is where Redux, Zustand, Pinia and similar solutions can shine. The problem isn’t global state. The problem is unnecessarily global state.
Redux Isn’t the Answer to Every State Problem
Suppose your application has:
1
const [modalOpen, setModalOpen] = useState(false);
Moving it into Redux:
1
dispatch(openModal());
doesn’t automatically improve the architecture.
Likewise, storing an API response in Redux doesn’t automatically solve server-state caching.
And storing filters in Redux may be worse than putting them in the URL.
Instead of asking:
Which state library should we use?
Ask:
Where should this specific piece of state live?
Then the answer may be:
1
2
3
4
5
6
7
Component
URL
Server cache
Form manager
Context
Global store
Browser storage
The tool follows the ownership model.
A Better State Architecture
Imagine an e-commerce application.
Instead of:
1
2
3
4
EVERYTHING
│
▼
Redux
we might have:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Application
│
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
UI State URL State Server State
│ │ │
useState Router Query Cache
│ │
▼ ▼
Components API
│
▼
Shared Client State
│
▼
Global Store
Now each type of state uses the mechanism suited to its job.
This can actually make an application simpler even though we’re using several different tools.
Because each tool has a clear responsibility.
Example: Building a Product Search Page
Let’s put this into practice.
Suppose we’re building:
1
/products
The page contains:
1
2
3
4
5
6
Search
Category filter
Sort order
Products
Cart
Filter drawer
Where should everything live?
Filter Drawer
1
isFilterDrawerOpen
This is local UI state.
1
2
const [isFilterDrawerOpen, setFilterDrawerOpen] =
useState(false);
Search and Filters
These affect what page the user is viewing and should be shareable.
1
/products?q=keyboard&category=accessories&sort=price
So:
1
URL State
is a strong choice.
Products
Products come from:
1
Backend API
So they’re:
1
Server State
A query cache could manage them.
Conceptually:
1
2
3
4
useQuery({
queryKey: ["products", search, category, sort],
queryFn: fetchProducts
});
Shopping Cart
The cart may be needed by:
1
2
3
4
Header
Product Page
Checkout
Sidebar
This could be:
1
Shared Client State
or server state depending on how the cart is persisted and synchronized.
That’s an architectural decision.
Number of Products
Suppose we already have:
1
products
Don’t necessarily store:
1
productCount
Just derive:
1
const productCount = products.length;
Now our architecture is much clearer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Product Search Page
│
├── Filter drawer ──► Local State
│
├── Search ─────────► URL
│
├── Category ───────► URL
│
├── Sort ───────────► URL
│
├── Products ───────► Server Cache
│
├── Cart ───────────► Shared State
│
└── Product Count ──► Derived
That’s state management.
Notice that we haven’t needed one giant store.
Why State Libraries Sometimes Make Things Worse
Suppose a team decides:
“Everything goes in Redux.”
Now adding a modal requires:
1
2
3
4
5
Action
Reducer
Selector
Dispatch
Store
even though:
1
useState(false)
would have solved the problem.
Or the team puts every API response into the store and manually implements:
1
2
3
4
5
Loading
Caching
Retries
Invalidation
Staleness
They end up rebuilding a server-state library.
Or they store pagination in global state and then write custom logic to synchronize it with the URL.
They’ve now created:
1
2
3
4
5
URL
↕
Redux
↕
Components
when:
1
2
3
URL
↓
Components
would have been simpler.
More powerful tools don’t automatically create simpler architecture.
Why State Libraries Sometimes Make Things Much Better
The opposite mistake is pretending every application can survive indefinitely on:
1
useState + props
Imagine a complex trading interface.
Dozens of components need access to:
1
2
3
4
5
6
7
Selected instrument
Open positions
Workspace layout
Active panels
Watchlists
Notifications
User preferences
Trying to thread everything through component hierarchies can become difficult.
A well-designed store can create:
1
2
3
4
Store
┌──────────┼──────────┐
▼ ▼ ▼
Chart Orders Watchlist
Components subscribe only to the state they need.
The state transitions can be centralized and inspected.
The lesson isn’t:
1
Global stores are bad.
It’s:
1
2
Use global stores for genuinely shared
client-side state.
State and Re-Renders
State architecture also affects rendering performance.
Suppose a global context contains:
1
2
3
4
5
6
7
8
{
user,
cart,
theme,
notifications,
sidebar,
preferences
}
A change to:
1
sidebar
may cause consumers of that context to re-evaluate even if they only care about:
1
user
depending on how the architecture is implemented.
This is one reason state libraries often provide selectors.
Conceptually:
1
2
3
4
5
6
7
Global Store
│
├── Component A subscribes to cart
│
├── Component B subscribes to user
│
└── Component C subscribes to theme
When:
1
cart changes
we ideally want to notify only consumers that care about the cart. State management isn’t only about storing information. It’s also about efficiently distributing changes.
State Has Identity
There’s another subtle issue.
Consider:
1
2
3
const user = {
name: "Billy"
};
Then:
1
2
3
const anotherUser = {
name: "Billy"
};
Their contents look identical.
But:
1
user === anotherUser
is:
1
false
because they’re different objects.
This matters in reactive systems because object identity can influence:
1
2
3
4
5
Change detection
Memoization
Dependencies
Selectors
Re-renders
Carelessly recreating objects can therefore produce unnecessary updates. State management isn’t only about what values exist. Sometimes it’s also about whether the system considers those values changed.
Immutable Updates
React developers often encounter patterns such as:
1
2
3
4
setUser({
...user,
name: "John"
});
instead of:
1
user.name = "John";
Why?
Because frameworks need reliable ways to understand that state changed.
With immutable-style updates:
1
2
3
4
5
6
7
Old State
│
▼
Create New State
│
▼
New Reference
the transition is easier to reason about.
For arrays:
1
2
3
4
5
6
7
setProducts(
products.map(product =>
product.id === updated.id
? updated
: product
)
);
rather than mutating the existing array.
Different frameworks have different reactivity models, but the broader principle remains:
Understand how your framework detects change.
Deeply Nested State Gets Painful
Consider:
1
2
3
4
5
6
7
8
9
const state = {
user: {
profile: {
address: {
city: "Nairobi"
}
}
}
};
Updating the city immutably can become awkward:
1
2
3
4
5
6
7
8
9
10
11
12
13
setState({
...state,
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: "Mombasa"
}
}
}
});
The problem is partly the update syntax.
But it’s also the state shape.
React’s guidance suggests flattening deeply nested state where practical because it makes updates easier and helps reduce duplication. (React)
Instead of thinking only:
How do I update this deeply nested object?
sometimes ask:
Should my state be shaped like this in the first place?
State Should Have a Source of Truth
Suppose a user’s name exists in:
1
2
3
4
5
Server
Redux
Header component
Profile form
localStorage
That’s five versions of:
1
Billy
Now the profile is changed.
1
2
3
4
Billy
│
▼
William
Which copies update?
1
2
3
4
5
Server William
Redux William
Header Billy
Profile Form William
localStorage Billy
Now the UI disagrees with itself.
A better architecture clearly defines:
1
2
3
4
Source of Truth
│
▼
Other representations
For example:
1
2
3
4
5
6
7
8
Server
│
▼
Query Cache
│
├── Header
├── Profile
└── Settings
One authoritative source.
Multiple consumers.
The State Management Decision Tree
Before adding state, ask:
Can this value be calculated from existing information?
If yes:
1
Derive it.
If no:
Does only one component need it?
If yes:
1
Keep it local.
If multiple nearby components need it:
1
Lift it to their common owner.
If it represents navigation or shareable page configuration:
1
Consider the URL.
If it came from a backend:
1
Treat it as server state.
If it’s complex form interaction:
1
Treat it as form state.
If it’s genuinely shared client information:
1
Consider Context or a store.
If it must survive sessions:
1
Consider persistence deliberately.
The process looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
New State
│
▼
Can it be derived?
/ \
Yes No
│ │
Derive Who owns it?
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Component URL Server
│ │
▼ ▼
Local State Query Cache
Shared broadly?
│
▼
Global Store
The Best State Is Often No State
This sounds strange in an article about state management.
But one of the strongest state-management techniques is simply removing unnecessary state.
Instead of:
1
2
3
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
const [hasItems, setHasItems] = useState(false);
store:
1
const [items, setItems] = useState([]);
and derive:
1
2
const itemCount = items.length;
const hasItems = items.length > 0;
We’ve gone from:
1
3 synchronized variables
to:
1
1 source of truth
React’s documentation emphasizes avoiding redundant and duplicate state precisely because fewer independent pieces of state are easier to keep consistent. (React)
That’s a lesson that applies far beyond React.
Bringing It All Together
Frontend state becomes complicated because we’re often using the word state to describe many different problems.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
STATE
│
┌───────────────┼────────────────┐
│ │ │
▼ ▼ ▼
UI State Server State URL State
│ │ │
▼ ▼ ▼
Component Query Cache Router
┌───────────────┼────────────────┐
│ │ │
▼ ▼ ▼
Form State Shared State Derived Data
│ │ │
▼ ▼ ▼
Form Manager Global Store Calculate
Trying to force all of these into one state-management solution creates complexity.
The better approach is to understand the ownership and lifecycle of each value.
Ask:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Where did this value come from?
Who owns it?
Who needs it?
How long should it live?
Can it be derived?
Does it need to survive navigation?
Does it belong in the URL?
Is the server actually the source of truth?
Once those questions are answered, the technology choice becomes much easier.
Final Thoughts
State management is often presented as a library problem. React developers debate Redux versus Zustand, Vue developers discuss Pinia, and other ecosystems introduce signals, stores, atoms, observables, and new reactive primitives. Those tools matter, but they come after the more important architectural decision.
You first need to model your state correctly. A badly designed state model doesn’t become good because you put it into Redux. Duplicated state doesn’t stop being duplicated because Zustand stores it. Server state doesn’t stop becoming stale because Context distributes it. And a filter that belongs in the URL doesn’t suddenly belong somewhere else because your state library has persistence.
Good frontend state management is mostly about maintaining clear ownership and minimizing sources of truth.
So instead of beginning with:
“Which state management library should we use?”
begin with:
“What kind of state is this, and where does it naturally belong?”
That question solves far more problems.
What’s Next?
So far in Beyond the UI, we’ve moved through several layers of the frontend:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Browser Rendering Pipeline
│
▼
Reflow and Repaint
│
▼
JavaScript Event Loop
│
▼
CSR vs SSR
│
▼
Hydration
│
▼
State Management
We’ve now seen how the browser renders an application, how JavaScript gets scheduled, how applications can be rendered on the server, how hydration makes them interactive, and how their changing data can be organized.
But there’s another performance problem hiding inside modern frontend applications. You update one small piece of state:
1
setCount(count + 1);
and suddenly multiple components execute again. Why? What does “re-render” actually mean? Does re-rendering mean the DOM was rebuilt? When does React actually touch the browser DOM? And when should you use tools such as memo, useMemo, and useCallback?
That’s where we’ll go next:
Re-Renders Explained: What Actually Happens When Frontend State Changes
Because before trying to prevent re-renders, you need to understand what a re-render actually costs.
