Devon Coleman | Published 7/11/25 |
There are two distinct classes of state typically encountered when building a sufficiently-complex FE webapp. Let’s talk about them!
Synced state is state that needs to be synchronized from/to some other source — the client (the user’s browser) is not the source of truth.
This is also often referred to as “server state”, but I think that term fails to encapsulate the fact that other non-authoritative sources of state exist in our platform (like IndexedDB).
The typical characteristics of synced state are:
Synced state nearly always necessitates tracking metadata about the state. We need to track things like “Has it been read?” or “Is the read in-flight?” or “Did the read fail?”
Often you’ll see this as a “status” field with UNINITIALIZED
/LOADING
/SUCCESS
/FAILURE
values, or (more commonly) data
/error
/loading
fields (such as provided by Apollo).
Regardless of how it is presented, this metadata generally lets us track and react to the “lifecycle” of synced state. The lifecycle is generally represented by a deceptively simple state machine:
flowchart TD
A(UNINITIALIZED) -->|fetch| B(LOADING)
B -.->|succeeds| C(SUCCESS)
C -->|refresh| B
B -.->|fails| D(FAILURE)
D -->|retry| B
This lifecycle is generally consistent for any piece of synced state, which leads people to think they can easily write an abstraction for it.
Sadly it’s much more complex than it seems, so most of the things people write are bad. As a sneak peek of the future: This is where a metric ton of accidental complexity sneaks in.
In constrast, client state is state that the client is authoritative over.
The typical characteristics of client state are:
If synced state is about keeping a local copy of data in sync with some other copy, client state is about handling user input/interaction.
The complexity with client state generally comes from deciding where to keep it. In a React app (the context for all of these discussions, as my day job is a React shop), there are three typical “levels” of state that define how broadly it reaches across the component tree:
Generally, I recommend starting at the lowest level possible (component state) and “hoisting” the state to higher levels as it becomes needed across more of the tree.
Do not mix client and synced state.
Synced state and client state should not be stored together.
If you store synced and client state in the same place, you’re in for a bad time.
Client + Synced state = pain.
Very, very often, developers want to put their client and synced states in the same container. Whether this is using redux for everything, or using reactive variables in apollo client, they want to “reduce complexity” by standardizing on a single source of truth for app state. But I’m here to say that this is in fact an antipattern and should not be done under any circumstances.
The reasoning is both simple and deep: The two types of state have very different needs, concerns, challenges, and opportunities. Trying to mix them inevitably results in problems because a pattern built for one type won’t cleanly work with another.
It also tightly couples your client state patterns with your synced state patterns. If you need to swap one out (moving your synced state into graphql, for example — extremely common in enterprise apps), you will find it very difficult unless you’ve correctly separated your concerns.
In the React world, the right way to combine these types of state is at the component layer, which frequently means a properly memoized custom hook.
In my experience, much of the accidental complexity in FE apps comes when synced state gets involved — so next up, we’ll be digging deeper into that.
Back to home | Next — Scaling State: Synced State |