Skip to content

Part 6.6 — Async State, Side Effects & Server State Boundaries

Site Console Site Console
4 min read Updated Jan 14, 2026 Frontend Design 0 comments

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

Leave a comment

Sign in to leave a comment.

Comments