Post

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.

This post is licensed under CC BY 4.0 by the author.