Part 6.6 — Async State, Side Effects & Server State Boundaries
Async state is where otherwise clean React architectures fall apart.
Loading flags get stuck. Errors leak across screens. Global stores turn into caches. Components become afraid to refactor because “data might be somewhere else.”
The root cause is almost always the same: UI state and server state are mixed together.
This final post of Part 6 shows you how to separate them cleanly—and how to manage async behavior without turning your app into a state machine nobody understands.
1. Two Kinds of State (Treat Them Differently)
There are only two categories you need to reason about:
UI state
what the user is doing
what the interface looks like
temporary and ephemeral
Examples:
loading spinners
open modals
selected tabs
form inputs
optimistic flags
Server state
data fetched from APIs
persisted in PostgreSQL
shared across views
refetchable and invalidatable
Examples:
users
posts
paginated lists
permissions
profile data
If you blur this line, bugs follow.
2. Why Server State Is Special
Server state is different because it has:
latency
failure modes
partial freshness
multiple consumers
invalidation rules
Treating it like local UI state leads to:
duplicated fetch logic
stale data
inconsistent loading flags
overgrown global stores
Server state must be pulled, not pushed around.
3. The Anti-Pattern: “Global Fetch State”
This is a common mistake:
{
users: [],
loading: false,
error: null,
}Stored globally and mutated by many components.
Problems:
unclear ownership
unrelated screens affect each other
difficult invalidation
race conditions
Global state is not a cache.
4. A Better Pattern: Fetch Where Data Is Used
Prefer this pattern:
function UsersPage() {
const [users, setUsers] = React.useState<User[]>([]);
const [status, setStatus] =
React.useState<'idle' | 'loading' | 'error'>('idle');
React.useEffect(() => {
setStatus('loading');
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setStatus('idle');
})
.catch(() => setStatus('error'));
}, []);
}Why this works:
ownership is local
lifecycle is clear
teardown is automatic
refactors are safe
Start here unless you have a strong reason not to.
5. Lift Server State Only When Necessary
You lift server state when:
multiple sibling routes need it
refetching is expensive
consistency matters across screens
Example:
user profile shown in header + settings page
Even then, lift it one level up, not globally by default.
6. Centralize Fetch Logic, Not State
The right abstraction is API functions, not shared state.
export async function getUsers() {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed');
return res.json();
}Components own:
when to fetch
how to render loading/error
API modules own:
URLs
request/response handling
This keeps concerns clean.
7. Async Side Effects Belong in Effects or Actions
Rules to keep you sane:
reducers never perform async work
Context providers may orchestrate side effects
components decide when to fetch
services decide how to fetch
Never hide async logic inside random utilities.
8. Coordinating UI State with Server State
UI state reacts to server state—it does not replace it.
Example:
if (status === 'loading') return <Spinner />;
if (status === 'error') return <Error />;
return <UserList users={users} />;UI state:
reflects server state
resets naturally on unmount
stays local
This prevents “stuck” UI.
9. Pagination, Filters & Query Parameters
Server state often depends on inputs:
page
limit
filters
sort order
Treat these as UI state that influences server state.
const [page, setPage] = useState(1);
useEffect(() => {
fetch(`/api/users?page=${page}`);
}, [page]);Don’t bake pagination into global stores unless absolutely necessary.
10. Caching: Be Explicit or Don’t Do It
Implicit caching is dangerous.
If you cache server data:
define invalidation rules
define refresh behavior
define ownership
If you don’t need caching yet—don’t add it.
Correctness beats cleverness.
11. Where Redux Fits in Server State (Carefully)
Redux can hold server state, but only when:
many distant components depend on it
updates must be synchronized
invalidation is explicit
Even then:
keep UI state out
normalize data
avoid one giant slice
Redux is a coordination tool—not a database.
12. Error Handling Is Part of State Design
Errors are not exceptions—they’re states.
Design for:
retry
empty state
partial failure
Don’t let errors “escape” into global state by accident.
13. The Mental Model to Keep
Always ask:
is this UI state or server state?
who owns it?
how long should it live?
who invalidates it?
If you can’t answer these, the design isn’t done yet.
14. Summary
You now understand how to:
separate UI state from server state
manage async flows without chaos
avoid global fetch anti-patterns
centralize API logic correctly
lift state only when justified
design predictable async behavior
This completes Part 6 — State Management in React.
Related
Part 6.5 — Introducing Redux Toolkit the Right Way
Redux isn’t the default anymore—but when your app needs it, Redux Toolkit is the cleanest, safest way to introduce global state at scale.
Part 6.4 — Combining Context + useReducer for App-Level State
Context provides access. Reducers provide structure. Together, they form a powerful, lightweight architecture for managing app-level state in React.
Part 6.3 — useReducer for Complex State Transitions
When state stops being simple values and starts behaving like a system, useReducer gives you structure, predictability, and clarity.
Comments