Basic Async Options
Use a schema factory function that accepts loaded data as arguments. Keep controller = $state(null) and only instantiate it after the fetch resolves.
Wrap the form in {#if controller} and show a skeleton or spinner while
loading.
1
2<script lang="ts">
3 import { onMount } from "svelte";
4 import { Schema, pipe } from "effect";
5 import { SchemaForm, FormController, withField, withFormLayout } from "@kareyes/aether/forms";
6
7 type Option = { value: string; label: string };
8
9 // Schema factory — takes the loaded options as an argument.
10 // Call this AFTER the data is ready, then pass the result to FormController.
11 function createPickerSchema(pokemonOptions: Option[]) {
12 return pipe(
13 Schema.Struct({
14 pokemon: pipe(
15 Schema.String,
16 Schema.minLength(1),
17 Schema.annotations({ message: () => "Please select a Pokémon" }),
18 withField({
19 label: "Select a Pokémon",
20 inputType: "select",
21 options: pokemonOptions
22 })
23 ),
24 nickname: pipe(
25 Schema.String,
26 withField({ label: "Nickname", placeholder: "Optional nickname", colSpan: 6 })
27 ),
28 level: pipe(
29 Schema.Number,
30 Schema.filter((n) => n >= 1 && n <= 100, {
31 message: () => "Level must be between 1 and 100"
32 }),
33 withField({ label: "Level", inputType: "number", colSpan: 6, description: "1–100" })
34 )
35 }),
36 withFormLayout({
37 columns: 12,
38 sections: [{ id: "main", title: "Pokémon Picker" }]
39 })
40 );
41 }
42
43 // Start null — rendered only after data loads
44 let controller = $state<FormController | null>(null);
45 let isLoading = $state(true);
46 let loadError = $state<string | null>(null);
47
48 onMount(async () => {
49 try {
50 const res = await fetch("https://pokeapi.co/api/v2/pokemon?limit=20");
51 const data = await res.json();
52
53 const options: Option[] = data.results.map((p: { name: string }) => ({
54 value: p.name,
55 label: p.name.charAt(0).toUpperCase() + p.name.slice(1)
56 }));
57
58 // Create schema and controller once options are ready
59 const schema = createPickerSchema(options);
60 controller = new FormController(schema, {
61 validateOnBlur: true,
62 initialValues: { level: 5 }
63 });
64 } catch {
65 loadError = "Failed to load Pokémon data. Please try again.";
66 } finally {
67 isLoading = false;
68 }
69 });
70</script>
71
72{#if isLoading}
73 <div class="flex items-center gap-2 text-muted-foreground py-8">
74 <span class="animate-spin">⟳</span> Loading Pokémon from PokéAPI...
75 </div>
76{:else if loadError}
77 <p class="text-destructive">{loadError}</p>
78{:else if controller}
79 <SchemaForm
80 {controller}
81 onSubmit={(d) => console.log("Picked:", d)}
82 submitText="Add to Team"
83 />
84{/if}Concurrent Loads with Promise.all
When a form needs data from multiple endpoints, use Promise.all to run all
fetches in parallel. Total load time equals the slowest request, not the sum of all. Pass
all loaded datasets into a single schema factory.
1
2<script lang="ts">
3 import { onMount } from "svelte";
4 import { Schema, pipe } from "effect";
5 import { SchemaForm, FormController, withField, withFormLayout } from "@kareyes/aether/forms";
6
7 type Option = { value: string; label: string };
8
9 // Schema factory takes multiple option arrays from concurrent fetches
10 function createTeamSchema(pokemonOpts: Option[], typeOpts: Option[]) {
11 return pipe(
12 Schema.Struct({
13 teamName: pipe(
14 Schema.String,
15 Schema.minLength(1),
16 Schema.annotations({ message: () => "Team name is required" }),
17 withField({ label: "Team Name", placeholder: "e.g. Fire Squad", colSpan: 8 })
18 ),
19 preferredType: pipe(
20 Schema.String,
21 withField({ label: "Preferred Type", inputType: "select", options: typeOpts, colSpan: 4 })
22 ),
23 slot1: pipe(
24 Schema.String,
25 withField({ label: "Slot 1", inputType: "select", options: pokemonOpts, colSpan: 6 })
26 ),
27 slot2: pipe(
28 Schema.String,
29 withField({ label: "Slot 2", inputType: "select", options: pokemonOpts, colSpan: 6 })
30 ),
31 slot3: pipe(
32 Schema.String,
33 withField({ label: "Slot 3", inputType: "select", options: pokemonOpts, colSpan: 6 })
34 ),
35 slot4: pipe(
36 Schema.String,
37 withField({ label: "Slot 4", inputType: "select", options: pokemonOpts, colSpan: 6 })
38 )
39 }),
40 withFormLayout({
41 columns: 12,
42 sections: [{ id: "main", title: "Pokémon Team Builder" }]
43 })
44 );
45 }
46
47 let controller = $state<FormController | null>(null);
48 let isLoading = $state(true);
49
50 onMount(async () => {
51 // Fetch both endpoints concurrently with Promise.all
52 const [pokemonData, typeData] = await Promise.all([
53 fetch("https://pokeapi.co/api/v2/pokemon?limit=20").then((r) => r.json()),
54 fetch("https://pokeapi.co/api/v2/type?limit=20").then((r) => r.json())
55 ]);
56
57 const pokemonOpts: Option[] = pokemonData.results.map((p: { name: string }) => ({
58 value: p.name,
59 label: p.name.charAt(0).toUpperCase() + p.name.slice(1)
60 }));
61
62 const typeOpts: Option[] = typeData.results
63 .filter((t: { name: string }) => !["unknown", "shadow"].includes(t.name))
64 .map((t: { name: string }) => ({
65 value: t.name,
66 label: t.name.charAt(0).toUpperCase() + t.name.slice(1)
67 }));
68
69 const schema = createTeamSchema(pokemonOpts, typeOpts);
70 controller = new FormController(schema, { validateOnBlur: true });
71 isLoading = false;
72 });
73</script>
74
75{#if isLoading}
76 <p class="text-muted-foreground py-4">Loading Pokémon and types...</p>
77{:else if controller}
78 <SchemaForm {controller} onSubmit={(d) => console.log("Team:", d)} submitText="Save Team" />
79{/if}Cascading / Dependent Loads
When one selection drives what data loads next, keep the "trigger" picker outside the SchemaForm. Use a plain Svelte reactive variable to hold
the selection, then call an async function on change to fetch new data and rebuild the
controller. The inner form re-renders with the new options.
Select a generation above to load its Pokémon
1
2<script lang="ts">
3 import { onMount } from "svelte";
4 import { Schema, pipe } from "effect";
5 import { SchemaForm, FormController, withField, withFormLayout } from "@kareyes/aether/forms";
6
7 type Option = { value: string; label: string };
8
9 function createBattleSchema(pokemonOpts: Option[]) {
10 return pipe(
11 Schema.Struct({
12 pokemon: pipe(
13 Schema.String,
14 Schema.minLength(1),
15 Schema.annotations({ message: () => "Select a Pokémon from this generation" }),
16 withField({
17 label: "Pokémon",
18 inputType: "select",
19 options: pokemonOpts,
20 description: `${pokemonOpts.length} Pokémon available`
21 })
22 ),
23 moveSet: pipe(
24 Schema.String,
25 withField({
26 label: "Move Strategy",
27 inputType: "radio",
28 options: [
29 { value: "balanced", label: "Balanced" },
30 { value: "offensive", label: "Offensive" },
31 { value: "defensive", label: "Defensive" }
32 ]
33 })
34 )
35 }),
36 withFormLayout({ columns: 1, sections: [{ id: "main", title: "Battle Config" }] })
37 );
38 }
39
40 // Generation selector lives outside the form — it controls what data loads
41 let generationOptions = $state<Option[]>([]);
42 let selectedGeneration = $state("");
43
44 // This controller is recreated each time a new generation is selected
45 let battleController = $state<FormController | null>(null);
46 let genLoading = $state(false);
47
48 // Load the generation list on mount
49 onMount(async () => {
50 const data = await fetch("https://pokeapi.co/api/v2/generation").then((r) => r.json());
51 const romanNumerals = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"];
52 generationOptions = data.results.map((g: { name: string }, i: number) => ({
53 value: g.name,
54 label: `Generation ${romanNumerals[i] ?? i + 1}`
55 }));
56 });
57
58 // When generation changes → fetch its Pokémon → build new controller
59 async function handleGenerationChange(genName: string) {
60 if (!genName) { battleController = null; return; }
61
62 genLoading = true;
63 battleController = null;
64
65 const data = await fetch(`https://pokeapi.co/api/v2/generation/${genName}`).then((r) => r.json());
66
67 const pokemonOpts: Option[] = data.pokemon_species
68 .slice(0, 30)
69 .sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name))
70 .map((p: { name: string }) => ({
71 value: p.name,
72 label: p.name.charAt(0).toUpperCase() + p.name.slice(1)
73 }));
74
75 battleController = new FormController(createBattleSchema(pokemonOpts), {
76 validateOnBlur: true
77 });
78 genLoading = false;
79 }
80</script>
81
82<!-- Generation picker sits outside the SchemaForm — it drives which data loads -->
83<div class="mb-4">
84 <label class="block text-sm font-medium mb-1">Generation</label>
85 <select
86 class="w-full border rounded px-3 py-2 text-sm"
87 bind:value={selectedGeneration}
88 onchange={() => handleGenerationChange(selectedGeneration)}
89 >
90 <option value="">Select a generation...</option>
91 {#each generationOptions as opt}
92 <option value={opt.value}>{opt.label}</option>
93 {/each}
94 </select>
95</div>
96
97{#if genLoading}
98 <p class="text-muted-foreground text-sm py-4">Loading Pokémon for this generation...</p>
99{:else if battleController}
100 <SchemaForm
101 {controller: battleController}
102 onSubmit={(d) => console.log("Battle config:", d)}
103 submitText="Ready for Battle!"
104 />
105{:else if selectedGeneration}
106 <p class="text-muted-foreground text-sm">No data loaded yet.</p>
107{/if}