Skip to main content

Async Errors and Store-Level Error State

A Store method that performs an async operation — an API call, a file read, anything that can fail — should not let an exception simply propagate up and out to whatever called it. If a component calls store.fetchUser() inside a click handler and that promise rejects, the rejection has nowhere meaningful to go; nothing in the template is listening for it, and it ends up as an unhandled rejection in the console at best.

Instead, treat failure as state. Give the Store an error property, catch the exception inside the method itself, and store it there. Because Store properties are reactive, any component observing that property reacts to a failure exactly the same way it reacts to any other change — no special error-handling wiring required on the component side.

Example​

This is the pattern @geastack-community/query@1.0.3's GeaQuery already follows:

import { Store } from '@geajs/core'

export class GeaQuery<T = unknown> extends Store {
data: T | null = null
isLoading = false
error: Error | null = null

constructor(private queryFn: () => Promise<T>) {
super()
}

async fetch(): Promise<void> {
this.isLoading = true
this.error = null

try {
this.data = await this.queryFn()
} catch (err) {
this.error = err as Error
} finally {
this.isLoading = false
}
}
}

The try/catch/finally lives entirely inside the Store. Callers never need their own error handling for this operation — they just read error:

import { Component } from '@geajs/core'
import { GeaQuery } from '@geastack-community/query'

export default class UserProfile extends Component {
query = new GeaQuery(() => fetch('/api/user').then(r => r.json()))

created() {
this.query.fetch()
}

template() {
if (this.query.error) {
return <p class="error">Failed to load: {this.query.error.message}</p>
}
if (this.query.isLoading) {
return <p>Loading…</p>
}
return <p>{this.query.data?.name}</p>
}
}

Why This Is Better Than Letting the Exception Propagate​

  • The failure is just another piece of reactive state. error behaves exactly like data or isLoading — no separate error-handling mechanism for the template to learn.
  • Every caller gets the same handling for free. If three different components call query.fetch(), none of them need their own try/catch — the Store already handled it once, centrally.
  • finally guarantees cleanup runs regardless of outcome. isLoading is reset to false whether the fetch succeeded or failed, so a component can't get stuck showing a permanent loading spinner after a failure.

A Note on Clearing Errors​

Notice that fetch() resets this.error = null at the start of each call, before the try block runs. Without this, a failed request followed by a successful retry would leave the stale error from the first attempt sitting alongside fresh data, and a component checking if (this.query.error) first would keep showing the old failure. Always clear error state at the start of a retryable operation, not just on success.