DEV Community

Cover image for Decoupling Async State from UI Lifecycles
Luciano0322
Luciano0322

Posted on

Decoupling Async State from UI Lifecycles

In my previous articles, I’ve consistently emphasized a core architectural principle: once the render layer no longer dictates the entire data flow, the boundaries between State, Derived State, and Effects become critical.

When we fall into the habit of stuffing every UI-affecting variable into generic "state," the system quickly loses its semantic structure. In modern frontend applications, this architectural gap becomes most glaring when dealing with asynchronous work.

Async data is never merely "a value that will appear in the future." It carries complex semantics regarding its source, temporal validity, cancellation, error recovery, and invalidation. If these semantics aren't modeled explicitly, they inevitably get pushed down into the UI framework’s lifecycle—indirectly patched together through component mounts, effect dependencies, and callback guards.

This brings us to the core question of this article:

What does a system lose when the correctness of async work is forced to depend on the UI lifecycle?

We are all incredibly familiar with this pattern:

const data = await fetchSomething()
setState(data)
Enter fullscreen mode Exit fullscreen mode

Or, using a standard UI framework hook:

useEffect(() => {
  let cancelled = false

  fetchSomething().then(result => {
    if (!cancelled) {
      setData(result)
    }
  })

  return () => {
    cancelled = true
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

There is nothing inherently wrong with this code for simple use cases. It’s intuitive and perfectly aligns with how Promises are designed to work: trigger the operation, wait for the resolution, and write the result back into state.

However, this mental model has a subtle downside. It encourages us to think of async work as simply calling setState after a Promise resolves. That may hold up for simple screens, but as an application grows, the model starts to expose structural problems.


Promise Only Describes Completion, Not Ownership

A Promise solves a very specific problem:

A piece of work will complete in the future, and it will either succeed or fail.

It can perfectly describe the transition from:

pending → fulfilled
Enter fullscreen mode Exit fullscreen mode

or:

pending → rejected
Enter fullscreen mode Exit fullscreen mode

But a Promise itself does not answer these questions:

  • Who owns this data?
  • Is the data still valid when the Promise resolves?
  • How does this result cascade to other derived data nodes?
  • If an error occurs, who owns the recovery state?
  • During a refetch, should we discard the old data or implement a stale-while-revalidate pattern?

A Promise describes the lifecycle of an execution, but it does not define where that execution belongs within the application’s broader data flow. The true challenge in frontend engineering isn't waiting for a Promise to finish; it's orchestrating how that result correctly enters the system. If we just shove the payload into component state, we are making the UI lifecycle responsible for data coordination that it was never designed to model.


setState Is Not the Boundary of Async Correctness

The responsibility of setState is simple:

Write a value into a state container and notify the UI that it may need to re-render.

But async correctness involves far more than assigning a value.

Take the classic race condition example:

const userId = state.userId

const user = await fetchUser(userId)

setUser(user)
Enter fullscreen mode Exit fullscreen mode

What happens if userId changes while the request is still in flight?

Imagine this sequence:

fetchUser(1) starts
fetchUser(2) starts
fetchUser(2) finishes first
fetchUser(1) finishes later
Enter fullscreen mode Exit fullscreen mode

If we simply call setState after each Promise resolves, the UI may end up showing:

userId = 2
user data = user 1
Enter fullscreen mode Exit fullscreen mode

This isn't a flaw in the Promise—it's faithfully delivering the payload. The real problem is that the system lacks a clear semantic layer for deciding whether this async result still belongs to the current data state.

So we start adding guards:

let requestId = 0

async function loadUser(userId: string) {
  const current = ++requestId
  const user = await fetchUser(userId)

  if (current === requestId) {
    setUser(user)
  }
}
Enter fullscreen mode Exit fullscreen mode

Or we use cancellation:

const controller = new AbortController()

fetch(url, { signal: controller.signal })
Enter fullscreen mode Exit fullscreen mode

These techniques are practical, and they do solve local problems.

But fundamentally, they are patching the same issue:

Async work has not been placed inside the semantics of the data flow. It is treated as an external process that pushes a result back into state after completion.


Loading, Error, and Data Are Not Independent States

Another common pattern is to split async work into several pieces of state:

const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
Enter fullscreen mode Exit fullscreen mode

Then we manually synchronize them during the request:

setLoading(true)
setError(null)

try {
  const result = await fetchSomething()
  setData(result)
} catch (err) {
  setError(err)
} finally {
  setLoading(false)
}
Enter fullscreen mode Exit fullscreen mode

From the UI perspective, splitting async work into data, loading, and error feels reasonable. The UI does need to render different things based on different async states. But semantically, these three values are not unrelated pieces of state. They describe different aspects of the same Async Resource.

In other words:

data, loading, and error should not just be scattered fields inside a component. They should be understood as different projections of the same resource state.

Otherwise, the system can easily enter inconsistent combinations:

loading = false
error = null
data = stale data
Enter fullscreen mode Exit fullscreen mode
loading = true
error = previous error
data = previous data
Enter fullscreen mode Exit fullscreen mode
loading = false
error = error
data = new data
Enter fullscreen mode Exit fullscreen mode

Some combinations are valid. Some are bugs. Some depend on product semantics.

For example, when reloading data, should the previous data be preserved?

status = loading
data = previous data
Enter fullscreen mode Exit fullscreen mode

This can be perfectly reasonable because the UI may want to display stale-while-revalidate behavior.

But if these values are just split across multiple setState calls, the system itself does not know which combinations are valid. The developer has to manually preserve these relationships in every component.


Effects Should Not Coordinate Every Async Task

In many frontend architectures, Effects are treated as the universal solution for async work. This is understandable. API requests, WebSockets, and timers are all interactions with the external world.

However, when Effects take on too much async coordination, they become the center of system complexity.

It is easy to end up with logic like this:

  • Effect A fetches data
  • Effect B listens to data and updates derived state
  • Effect C writes derived state into cache
  • Effect D listens to mutation results and triggers refetch

On the surface, this looks reactive. But in reality, it has degraded into a process network maintained by execution order.

Correctness no longer depends on clear dependencies. It depends on:

  • Which Effect runs first?
  • Which state is cleared first?
  • Which callback observes which intermediate value?

The nature of an Effect is to execute a side effect, not to express a data relationship. If starting requests, cancelling requests, checking validity, synchronizing status, and updating cache are all pushed into Effects, the problem is not merely that the code becomes longer.

The real problem is that the semantic boundaries of the architecture have collapsed.


Async Results Carry Temporal Semantics

Synchronous state changes are relatively intuitive:

count = 1
count = 2
count = 3
Enter fullscreen mode Exit fullscreen mode

But async results are different. An async result carries time semantics. It is not merely: value = result

It also implies:

  • Which request produced this result?
  • Is this result later than the current state?
  • Does this result still correspond to the current source?
  • Has this result already become stale?
  • Should this result overwrite the existing data?

So async state is not ordinary state. It is data state with source, time, and validity semantics. If we simplify it into “call setState after the Promise resolves,” we are hiding those temporal semantics.

In the short term, the code may look simpler. But in the long term, the complexity does not disappear. It only moves somewhere harder to trace.


From “The Request Completed” to “The Resource State Changed”

The mindset shift is this:

Do not only care whether an async function has completed.
Care about how the state of an async resource changes as its dependencies change.

In other words, we move from this:

fetch completes → setState
Enter fullscreen mode Exit fullscreen mode

To this:

source changes → resource re-evaluates
resource state changes → dependent nodes in the graph update
Enter fullscreen mode Exit fullscreen mode

The former is an imperative process. The latter is a data flow model.

In the former model, the developer has to manually decide every step:

  • When should we fetch?
  • When should loading be set?
  • When should data be set?
  • When should error be set?
  • When should a stale result be ignored?
  • When should refetch happen?

In the latter model, these concerns can be wrapped into the semantics of a Resource:

  • value
  • status
  • error
  • reload
  • cancel
  • invalidate

This does not mean developers should lose control over async work. Quite the opposite. A good async model should make control more explicit.

But that control should not be scattered across render functions and effect chains. It should be concentrated at the semantic boundary of the Resource.


Treating Async Work as a Data Node in the Reactive Graph

When I first started building signal-kernel, my focus was on basic reactive primitives:

  • Signal
  • Computed
  • Effect

But when I began working on Async Resources, I realized that async work is directly related to the correctness of the entire reactive graph.

In real applications, data is rarely produced only synchronously.

Data often comes from:

  • API requests
  • WebSockets
  • Streams
  • Background tasks
  • User actions
  • Mutation responses
  • Cache hydration
  • Server snapshots

If all async work can only complete outside the graph and then push its result back through setState, the reactive graph can only guarantee so much. It can guarantee updates for synchronous dependencies.

But it can't fully handle the semantics introduced by async work:

  • time
  • cancellation
  • invalidation
  • reload
  • errors

So the purpose of async-runtime is not to wrap Promise in a nicer API.

Its core idea is:

Async work should become a trackable, cancellable, invalidatable, observable data node inside the reactive graph.

This is also why I increasingly do not see Async Resource as merely a UI data-fetching problem. It is an extension of data flow ownership.


UI Should Not Be the Only Owner of Async Correctness

In traditional frontend thinking, async work is often tied to the component lifecycle:

  • Fetch when the component mounts
  • Cancel when the component unmounts
  • Refetch when dependencies change
  • Call setState when the Promise resolves

For simple screens, this is intuitive and completely reasonable. The problem appears when the scope of async data is much larger than a single component.

For example:

  • User information
  • Permission data
  • Shopping cart state
  • Notification state
  • Background synchronization
  • Mutation results
  • Cache hydration
  • AI agent execution state

These pieces of data may be consumed by multiple screens, multiple components, multiple derived states, and multiple effects at the same time.

If their lifecycle is fully attached to a single component, the system starts to become rigid and inconsistent:

  • The component unmounts, but the data still needs to be preserved.
  • The component mounts again, and the same data is requested again.
  • A mutation succeeds, but the system does not know which resources should be invalidated.
  • One screen has updated, but another screen is still showing stale data.

So the point is not that UI should never use async state.

The point is:

UI should not be the only owner of async correctness.

Promise + setState is a perfectly valid implementation detail for one-off forms, local modals, and short-lived component state. But if it becomes the async architecture of the entire application, the system quickly loses its boundaries.

Only when async work is formally brought into the semantic boundary of the reactive graph can the system pull async correctness back from scattered control flow and restore it as an understandable, traceable, and maintainable data structure.

In the next article, I will discuss another problem that is even easier to confuse:

Query, Mutation, and Stream are not the same kind of async work.

They all look related to async operations, but their semantics in the data flow are fundamentally different. If we treat all of them as merely “a Promise,” the system will quickly lose its boundaries again.

Top comments (0)