Skip to content

Chapter 60: Application 2: Profile Editor

The second application introduces async data loading.

The settings panel from Chapter 59 worked with values that were either server-rendered into the markup or empty. The profile editor in this chapter loads the user’s profile from a server at mount, displays it in a form, lets the user edit, and saves the result. The async loading, the repository pattern, the adapter mapping between server and client shapes — all are pieces from Chapter 43 that this application exercises.

A Profile Editor page that:

  1. On mount, loads the current user’s profile from GET /api/profile/me.
  2. Renders the profile in an editable form.
  3. Lets the user modify fields and save.
  4. On save, sends PUT /api/profile/me with the updated values.
  5. Handles loading states, error states, and the empty initial state.
  6. Persists drafts to local storage so the user doesn’t lose work on accidental navigation.

The application is small but architecturally interesting. The data layer is the focus.

The application’s data layer starts with a ProfileRepository:

import { createProviderToken } from '@kitsune/core'
export interface Profile {
id: string
displayName: string
email: string
bio: string
avatarUrl?: string
createdAt: Date
}
export interface ProfileRepository {
load(): Promise<Profile>
save(updates: Partial<Profile>): Promise<Profile>
}
export const PROFILE_REPO = createProviderToken<ProfileRepository>('profile-repository')

The interface is what the modules consume. The implementation handles the server-side specifics:

interface ServerProfileShape {
id: string
display_name: string
email: string
bio: string
avatar_url?: string | null
created_at: string // ISO 8601
}
const profileAdapter = {
toClient(server: ServerProfileShape): Profile {
return {
id: server.id,
displayName: server.display_name,
email: server.email,
bio: server.bio,
avatarUrl: server.avatar_url ?? undefined,
createdAt: new Date(server.created_at)
}
},
toServerUpdates(updates: Partial<Profile>): Partial<ServerProfileShape> {
const out: Partial<ServerProfileShape> = {}
if ('displayName' in updates) out.display_name = updates.displayName!
if ('email' in updates) out.email = updates.email!
if ('bio' in updates) out.bio = updates.bio!
return out
}
}
export function createApiProfileRepository(): ProfileRepository {
return {
async load() {
const response = await fetch('/api/profile/me')
if (!response.ok) throw new Error(`Load failed: ${response.status}`)
const data = await response.json()
return profileAdapter.toClient(data)
},
async save(updates) {
const response = await fetch('/api/profile/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profileAdapter.toServerUpdates(updates))
})
if (!response.ok) throw new Error(`Save failed: ${response.status}`)
const data = await response.json()
return profileAdapter.toClient(data)
}
}
}

The repository is a focused, testable, swappable unit. The adapter handles the server-vs-client shape mismatch in one place. The implementation can be swapped (a mock for tests, an offline-first version that uses IndexedDB, an entirely different storage backend) without changing anything that consumes PROFILE_REPO.

The form needs the profile data before it can render. The application uses an <async-widget>-style pattern to handle the loading state:

<kit-shell name="profile-editor">
<kit-boundary surface="profile-editor" feature="account" entity-type="profile" entity-id="me">
<kit-async-widget loader=${async () => runtime.inject(PROFILE_REPO)!.load()}>
<div slot="loading">Loading profile…</div>
<div slot="error">Couldn't load your profile. <button data-meta-command="profile.retry_load">Try again</button></div>
<div slot="loaded">
<form
data-meta-event="profile.save_requested"
data-meta-prop-prevent-default="true"
>
<kit-field label="Display name" required>
<kit-text-field name="displayName" required></kit-text-field>
</kit-field>
<kit-field label="Email" required>
<kit-text-field name="email" type="email" required></kit-text-field>
</kit-field>
<kit-field label="Bio">
<kit-text-field name="bio" multiline></kit-text-field>
</kit-field>
<kit-button type="submit" variant="primary">Save</kit-button>
</form>
</div>
</kit-async-widget>
</kit-boundary>
</kit-shell>

The <kit-async-widget> (from Chapter 43’s pattern) handles the loading lifecycle — calling the loader, displaying the loading slot until the loader resolves, displaying the error slot on failure, displaying the loaded slot on success.

The architecture’s contribution: when the loaded slot’s content includes form fields, the application populates them with the loaded data. The async-widget exposes the loaded data through a custom-element property that the inner form’s fields can read.

For a working implementation, the inner form’s fields can subscribe to the widget’s loaded event:

widget.addEventListener('async-widget:loaded', (event) => {
const profile = event.detail.data as Profile
const form = widget.querySelector('form')!
;(form.elements.namedItem('displayName') as HTMLInputElement).value = profile.displayName
;(form.elements.namedItem('email') as HTMLInputElement).value = profile.email
;(form.elements.namedItem('bio') as HTMLInputElement).value = profile.bio
})

The pattern is small. The architecture doesn’t impose a specific approach to how the loaded data populates the form; the team picks whatever works for their template engine.

When the user submits the form, the architecture handles it through the same orchestrator pattern as the settings panel:

const profileOrchestrator = defineKitModule({
name: 'profile-orchestrator',
dependsOn: ['profile-repository'],
events: {
'profile.save_requested': async (event) => {
const repo = runtime.inject(PROFILE_REPO)!
try {
const updated = await repo.save(event.payload.data)
runtime.emit({ type: 'profile.saved', context: event.context, payload: updated })
runtime.command({ type: 'notification.show', payload: { message: 'Profile saved' } })
} catch (error) {
runtime.emit({
type: 'profile.save_failed',
context: event.context,
payload: { error: String(error), retryable: isRetryable(error) }
})
runtime.command({ type: 'notification.show', payload: { message: 'Couldn\'t save', variant: 'error' } })
}
}
}
})

The orchestrator handles the round-trip. The form fires profile.save_requested; the orchestrator calls the repository; success emits profile.saved; failure emits profile.save_failed with retry information.

The user’s edits should survive accidental navigation. A draft storage module listens to input events on the form and persists the current values to localStorage:

const draftStorageModule = defineKitModule({
name: 'draft-storage',
onStart: ({ runtime }) => {
// Save drafts on input
document.addEventListener('input', (event) => {
const form = (event.target as Element).closest('form[data-meta-event="profile.save_requested"]')
if (!form) return
const data = Object.fromEntries(new FormData(form as HTMLFormElement))
localStorage.setItem('draft.profile', JSON.stringify(data))
})
// Clear drafts after successful save
runtime.on('profile.saved', () => {
localStorage.removeItem('draft.profile')
})
// Restore drafts on initial load
const draft = localStorage.getItem('draft.profile')
if (draft) {
runtime.emit({ type: 'draft.restored', payload: JSON.parse(draft) })
}
}
})

The module’s onStart subscribes to input events, persists the current form data on every keystroke, and clears the draft on successful save. The architecture’s separation of concerns shows up — the form doesn’t import the draft module; the draft module doesn’t import the form. They communicate through DOM events and runtime events.

Async data loading. The async-widget handles the loading lifecycle declaratively. The form populates from the loaded data.

The repository pattern. The PROFILE_REPO provider is the interface; the implementation handles the server-specific details. Tests can swap the implementation; offline-first features can swap the implementation; the consumers don’t care.

The adapter pattern. profileAdapter.toClient and profileAdapter.toServerUpdates handle the server-vs-client shape mismatch in one place.

Validation at the boundary. The adapter could include schema validation (using Zod or similar) to catch bad server responses early.

Draft persistence as a module. The cross-cutting concern of don’t lose the user’s work is handled by a module that observes input events, not by the form component.

Retry semantics. The orchestrator’s failure event includes retryable — the UI can decide whether to show a Retry button based on the error type.

The repository can be tested in isolation:

test('profileAdapter converts server shape to client', () => {
const server = {
id: 'p1',
display_name: 'Jeremy',
email: 'j@example.com',
bio: 'hi',
avatar_url: null,
created_at: '2026-01-01T00:00:00Z'
}
const client = profileAdapter.toClient(server)
expect(client.displayName).toBe('Jeremy')
expect(client.avatarUrl).toBeUndefined()
expect(client.createdAt).toBeInstanceOf(Date)
})
test('createApiProfileRepository handles errors', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 } as any)
const repo = createApiProfileRepository()
await expect(repo.load()).rejects.toThrow('Load failed: 500')
})

The full flow can be tested with the architecture’s helpers:

test('profile editor loads, edits, and saves', async () => {
const repo = createFakeProfileRepository({ initial: testProfile })
const { shell, runtime } = await createTestShell({
modules: [
defineKitModule({ name: 'profile-repository', providers: [{ token: PROFILE_REPO, value: repo }] }),
profileOrchestrator,
analyticsModule({ provider: trackingMock })
],
template: profileEditorTemplate
})
// Wait for the initial load
await waitForEvent(runtime, 'async-widget:loaded')
// Verify form populated
const form = shell.querySelector('form')!
expect((form.elements.namedItem('displayName') as HTMLInputElement).value).toBe('Initial Name')
// Edit and submit
setText(form, 'displayName', 'New Name')
form.requestSubmit()
await waitForEvent(runtime, 'profile.saved')
// Verify repository called
expect(repo.saves).toHaveLength(1)
expect(repo.saves[0].displayName).toBe('New Name')
await shell.stop()
})

The test exercises the full flow — load, edit, save — through the architecture. The repository mock captures what was saved; the assertions verify the round-trip.

Chapter 61 takes the next application — a design token editor with reactivity from storage, CSS custom property generation, and runtime theme switching. The token editor exercises the storage as the state layer pattern (Chapter 25) more rigorously than the previous two applications.

Implement the profile editor from this chapter.

The complete application:

  1. The repository implementation with adapters.
  2. The async-loading markup using <kit-async-widget>.
  3. The orchestrator module.
  4. The draft-storage module.
  5. The capability modules (analytics, notifications) from the previous chapter.

Then verify:

  1. Mount the page. Watch the loading state, then the loaded state.
  2. Edit a field. Verify the draft persists in localStorage.
  3. Submit. Verify the save completes and the toast appears.
  4. Refresh the page mid-edit (don’t submit first). Verify the draft restores from localStorage.
  5. Simulate a server error (mock the repository to throw). Verify the error path produces the right events and the right toast.

The profile editor is the architecture handling async data without losing the loose coupling between form and modules. The repository is what makes the data layer testable and swappable; the orchestrator is what bridges the form to the repository.