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.
The Product
Section titled “The Product”A Profile Editor page that:
- On mount, loads the current user’s profile from
GET /api/profile/me. - Renders the profile in an editable form.
- Lets the user modify fields and save.
- On save, sends
PUT /api/profile/mewith the updated values. - Handles loading states, error states, and the empty initial state.
- 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 Repository
Section titled “The Repository”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 Async-Loading Pattern
Section titled “The Async-Loading Pattern”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.
The Save Flow
Section titled “The Save Flow”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.
Draft Persistence
Section titled “Draft Persistence”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.
Patterns the Application Exercises
Section titled “Patterns the Application Exercises”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.
Testing the Profile Editor
Section titled “Testing the Profile Editor”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.
Bridge to the Token Editor
Section titled “Bridge to the Token Editor”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.
Exercise: Build the Profile Editor
Section titled “Exercise: Build the Profile Editor”Implement the profile editor from this chapter.
The complete application:
- The repository implementation with adapters.
- The async-loading markup using
<kit-async-widget>. - The orchestrator module.
- The draft-storage module.
- The capability modules (analytics, notifications) from the previous chapter.
Then verify:
- Mount the page. Watch the loading state, then the loaded state.
- Edit a field. Verify the draft persists in
localStorage. - Submit. Verify the save completes and the toast appears.
- Refresh the page mid-edit (don’t submit first). Verify the draft restores from
localStorage. - 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.