EpicenterHQ / sync-construction-async-property-ui-render-gate-pattern

Sync construction with async property pattern. Use when creating clients that need async initialization but must be exportable from modules and usable synchronously in UI components.

0 views
0 installs

Skill Content

---
name: sync-construction-async-property-ui-render-gate-pattern
description: Sync construction with async property pattern for module-exportable clients. Use when the user says "async init", "module-level async", or when creating clients that need async initialization but must be exportable from modules and usable synchronously in UI components.
metadata:
  author: epicenter
  version: '1.0'
---

# Sync Construction, Async Property

> The initialization of the client is synchronous. The async work is stored as a property you can await, while passing the reference around.

## When to Apply This Pattern

Use this when you have:

- Async client initialization (IndexedDB, server connection, file system)
- Module exports that need to be importable without `await`
- UI components that want sync access to the client
- SvelteKit apps where you want to gate rendering on readiness

Signals you're fighting async construction:

- `await getX()` patterns everywhere
- Top-level await complaints from bundlers
- Getter functions wrapping singleton access
- Components that can't import a client directly

## The Problem

Async constructors can't be exported:

```typescript
// This doesn't work
export const client = await createClient(); // Top-level await breaks bundlers
```

So you end up with getter patterns:

```typescript
let client: Client | null = null;

export async function getClient() {
	if (!client) {
		client = await createClient();
	}
	return client;
}

// Every consumer must await
const client = await getClient();
```

Every call site needs `await`. You're passing promises around instead of objects.

## The Pattern

Make construction synchronous. Attach async work to the object:

```typescript
// client.ts
export const client = createClient();

// Sync access works immediately
client.save(data);
client.load(id);

// Await the async work when you need to
await client.whenSynced;
```

Construction returns immediately. The async initialization (loading from disk, connecting to servers) happens in the background and is tracked via `whenSynced`.

## The UI Render Gate

In Svelte, gate once at the root using `@epicenter/ui/spinner` for the loading state and `@epicenter/ui/empty` for error recovery:

```svelte
<!-- +layout.svelte -->
<script>
	import * as Empty from '@epicenter/ui/empty';
	import { Spinner } from '@epicenter/ui/spinner';
	import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
	import { client } from '$lib/client';
</script>

{#await client.whenSynced}
	<Empty.Root class="flex-1">
		<Empty.Media>
			<Spinner class="size-5 text-muted-foreground" />
		</Empty.Media>
		<Empty.Title>Loading…</Empty.Title>
	</Empty.Root>
{:then}
	{@render children?.()}
{:catch}
	<Empty.Root class="flex-1">
		<Empty.Media>
			<TriangleAlertIcon class="size-8 text-muted-foreground" />
		</Empty.Media>
		<Empty.Title>Failed to load</Empty.Title>
		<Empty.Description>
			Something went wrong during initialization. Try reloading.
		</Empty.Description>
	</Empty.Root>
```

The gate guarantees: by the time any child component's script runs, the async work is complete. Children use sync access without checking readiness.

**Always include `{:catch}`** — if the async seed fails (e.g. `browser.windows.getAll` throws), the user sees an actionable error instead of an infinite spinner.

## Implementation

The `withCapabilities()` fluent builder attaches async work to a sync-constructed object:

```typescript
function createClient() {
	const state = initializeSyncState();

	return {
		save(data) {
			/* sync method */
		},
		load(id) {
			/* sync method */
		},

		withCapabilities({ persistence }) {
			const whenSynced = persistence(state);
			return Object.assign(this, { whenSynced });
		},
	};
}

// Usage
export const client = createClient().withCapabilities({
	persistence: (state) => loadFromIndexedDB(state),
});
```

## Before and After

| Aspect         | Async Construction        | Sync + whenSynced       |
| -------------- | ------------------------- | ----------------------- |
| Module export  | Can't export directly     | Export the object       |
| Consumer code  | `await getX()` everywhere | Direct import, sync use |
| UI integration | Awkward promise handling  | Single `{#await}` gate  |
| Type signature | `Promise<X>`              | `X` with `.whenSynced`  |

## Real-World Example: y-indexeddb

The Yjs ecosystem uses this pattern everywhere:

```typescript
const provider = new IndexeddbPersistence('my-db', doc);
// Constructor returns immediately

provider.on('update', handleUpdate); // Sync access works

await provider.whenSynced; // Wait when you need to
```

They never block construction. The async work is always deferred to a property you can await.

## Alternate Pattern: Await in Every Method

Alternatively, you can skip the `whenReady` property entirely and hide the initialization await inside each method. The canonical example is [idb](https://github.com/jakearchibald/idb):

```typescript
const dbPromise = openDB('keyval-store', 1, { upgrade(db) { db.createObjectStore('keyval') } });

export async function get(key) { return (await dbPromise).get('keyval', key); }
export async function set(key, val) { return (await dbPromise).put('keyval', val, key); }
```

Use `whenReady` when your client has sync methods that depend on initialized state. Use await-in-every-method when every method is async anyway (like database access). See the [idb await-in-every-method article](/docs/articles/idb-await-every-method-pattern.md) for a deeper comparison.

## Related Patterns

- [Lazy Singleton](../lazy-singleton/SKILL.md) — when you need race-condition-safe lazy initialization
- [Don't Use Parallel Maps](../../docs/articles/instance-state-attachment-pattern.md) — attach state to instances instead of tracking separately

## References

- [Full article](/docs/articles/sync-construction-async-property-ui-render-gate-pattern.md) — detailed explanation with diagrams
- [Comprehensive guide](/docs/articles/sync-client-initialization.md) — 480-line deep dive with idb example
- [idb await-in-every-method](/docs/articles/idb-await-every-method-pattern.md) — the sibling pattern for purely async APIs