[{"data":1,"prerenderedAt":645},["ShallowReactive",2],{"content-\u002Fdocs\u002Fmultistep\u002Fresumable-wizards":3},{"id":4,"title":5,"body":6,"description":624,"extension":625,"meta":626,"metaRows":627,"navigation":150,"path":640,"seo":641,"source":642,"stem":643,"__hash__":644},"docs\u002Fdocs\u002Fmultistep\u002Fresumable-wizards.md","Resumable wizards",{"type":7,"value":8,"toc":617},"minimark",[9,13,33,36,41,44,96,100,103,465,482,486,489,539,549,553,566,573,577,613],[10,11,5],"h1",{"id":12},"resumable-wizards",[14,15,16],"blockquote",{},[17,18,19,20,24,25,28,29,32],"p",{},"A multistep flow a user can leave and return to needs three things restored: the active step, each form's values, and which gates the session has cleared. Attaform already round-trips the step through the URL, values ride in through each form's ",[21,22,23],"code",{},"defaultValues",", and gate clearance rides in through ",[21,26,27],{},"useWizard","'s ",[21,30,31],{},"defaultStatuses",". All three load from one fetch, and none of them needs a table of its own.",[34,35],"docs-meta-table",{},[37,38,40],"h2",{"id":39},"the-three-things-a-wizard-restores","The three things a wizard restores",[17,42,43],{},"A resumable wizard is three pieces of state, each with its own channel:",[45,46,47,65,77],"ul",{},[48,49,50,54,55,60,61,64],"li",{},[51,52,53],"strong",{},"The active step",", handled for you by ",[56,57,59],"a",{"href":58},"\u002Fdocs\u002Fmultistep\u002Furl-sync","URL sync",". ",[21,62,63],{},"?step=\u003Ckey>"," is read at construction and written on navigation, so a reload lands where the user left off.",[48,66,67,70,71,76],{},[51,68,69],{},"Each form's values",", restored through that form's ",[56,72,74],{"href":73},"\u002Fdocs\u002Fschemas\u002Fdefaults",[21,75,23],{},". Validity, dirty, and error counts are recomputed from the restored values, so you never store them.",[48,78,79,82,83,88,89,95],{},[51,80,81],{},"Gate clearance",", restored through ",[56,84,86],{"href":85},"\u002Fdocs\u002Fmultistep\u002Fstatuses",[21,87,31],{},". A gate's cleared state is the one fact you cannot recompute from values, because \"the box validates\" is not \"the prerequisite was confirmed\" (that split is the whole reason ",[56,90,92],{"href":91},"\u002Fdocs\u002Fmultistep\u002Fgate",[21,93,94],{},"gate"," exists). So it is the one status worth persisting.",[37,97,99],{"id":98},"one-fetch-one-row","One fetch, one row",[17,101,102],{},"Load the session once and feed the three channels from the same payload. The gate flags are a small JSON column on the row you already keep for the form values, so a resumable wizard adds no tables:",[104,105,110],"pre",{"className":106,"code":107,"language":108,"meta":109,"style":109},"language-ts shiki shiki-themes github-light github-dark","import { useForm, useWizard, gate } from 'attaform'\nimport { z } from 'zod'\n\nconst consentSchema = z.object({ accepted: z.literal(true) })\nconst shippingSchema = z.object({ address: z.string(), city: z.string() })\n\n\u002F\u002F What you persist per session: each form's values, plus a `gates` column.\ntype SavedSession = {\n  consent: { accepted: true }\n  shipping: { address: string; city: string }\n  gates: Record\u003Cstring, { gate: 'cleared' | 'uncleared' }>\n}\n\n\u002F\u002F One call returns the whole row.\nconst saved: SavedSession = await loadSession(sessionId)\n\nconst consent = useForm({ schema: consentSchema, key: 'consent', defaultValues: saved.consent })\nconst shipping = useForm({ schema: shippingSchema, key: 'shipping', defaultValues: saved.shipping })\n\nconst wizard = useWizard({\n  steps: [gate(consent), shipping],\n  defaultStatuses: saved.gates, \u002F\u002F e.g. { consent: { gate: 'cleared' } }\n})\n","ts","",[21,111,112,132,145,152,187,215,220,227,241,265,295,330,336,341,347,370,375,397,418,423,439,450,459],{"__ignoreMap":109},[113,114,117,121,125,128],"span",{"class":115,"line":116},"line",1,[113,118,120],{"class":119},"szBVR","import",[113,122,124],{"class":123},"sVt8B"," { useForm, useWizard, gate } ",[113,126,127],{"class":119},"from",[113,129,131],{"class":130},"sZZnC"," 'attaform'\n",[113,133,135,137,140,142],{"class":115,"line":134},2,[113,136,120],{"class":119},[113,138,139],{"class":123}," { z } ",[113,141,127],{"class":119},[113,143,144],{"class":130}," 'zod'\n",[113,146,148],{"class":115,"line":147},3,[113,149,151],{"emptyLinePlaceholder":150},true,"\n",[113,153,155,158,162,165,168,172,175,178,181,184],{"class":115,"line":154},4,[113,156,157],{"class":119},"const",[113,159,161],{"class":160},"sj4cs"," consentSchema",[113,163,164],{"class":119}," =",[113,166,167],{"class":123}," z.",[113,169,171],{"class":170},"sScJk","object",[113,173,174],{"class":123},"({ accepted: z.",[113,176,177],{"class":170},"literal",[113,179,180],{"class":123},"(",[113,182,183],{"class":160},"true",[113,185,186],{"class":123},") })\n",[113,188,190,192,195,197,199,201,204,207,210,212],{"class":115,"line":189},5,[113,191,157],{"class":119},[113,193,194],{"class":160}," shippingSchema",[113,196,164],{"class":119},[113,198,167],{"class":123},[113,200,171],{"class":170},[113,202,203],{"class":123},"({ address: z.",[113,205,206],{"class":170},"string",[113,208,209],{"class":123},"(), city: z.",[113,211,206],{"class":170},[113,213,214],{"class":123},"() })\n",[113,216,218],{"class":115,"line":217},6,[113,219,151],{"emptyLinePlaceholder":150},[113,221,223],{"class":115,"line":222},7,[113,224,226],{"class":225},"sJ8bj","\u002F\u002F What you persist per session: each form's values, plus a `gates` column.\n",[113,228,230,233,236,238],{"class":115,"line":229},8,[113,231,232],{"class":119},"type",[113,234,235],{"class":170}," SavedSession",[113,237,164],{"class":119},[113,239,240],{"class":123}," {\n",[113,242,244,248,251,254,257,259,262],{"class":115,"line":243},9,[113,245,247],{"class":246},"s4XuR","  consent",[113,249,250],{"class":119},":",[113,252,253],{"class":123}," { ",[113,255,256],{"class":246},"accepted",[113,258,250],{"class":119},[113,260,261],{"class":160}," true",[113,263,264],{"class":123}," }\n",[113,266,268,271,273,275,278,280,283,286,289,291,293],{"class":115,"line":267},10,[113,269,270],{"class":246},"  shipping",[113,272,250],{"class":119},[113,274,253],{"class":123},[113,276,277],{"class":246},"address",[113,279,250],{"class":119},[113,281,282],{"class":160}," string",[113,284,285],{"class":123},"; ",[113,287,288],{"class":246},"city",[113,290,250],{"class":119},[113,292,282],{"class":160},[113,294,264],{"class":123},[113,296,298,301,303,306,309,311,314,316,318,321,324,327],{"class":115,"line":297},11,[113,299,300],{"class":246},"  gates",[113,302,250],{"class":119},[113,304,305],{"class":170}," Record",[113,307,308],{"class":123},"\u003C",[113,310,206],{"class":160},[113,312,313],{"class":123},", { ",[113,315,94],{"class":246},[113,317,250],{"class":119},[113,319,320],{"class":130}," 'cleared'",[113,322,323],{"class":119}," |",[113,325,326],{"class":130}," 'uncleared'",[113,328,329],{"class":123}," }>\n",[113,331,333],{"class":115,"line":332},12,[113,334,335],{"class":123},"}\n",[113,337,339],{"class":115,"line":338},13,[113,340,151],{"emptyLinePlaceholder":150},[113,342,344],{"class":115,"line":343},14,[113,345,346],{"class":225},"\u002F\u002F One call returns the whole row.\n",[113,348,350,352,355,357,359,361,364,367],{"class":115,"line":349},15,[113,351,157],{"class":119},[113,353,354],{"class":160}," saved",[113,356,250],{"class":119},[113,358,235],{"class":170},[113,360,164],{"class":119},[113,362,363],{"class":119}," await",[113,365,366],{"class":170}," loadSession",[113,368,369],{"class":123},"(sessionId)\n",[113,371,373],{"class":115,"line":372},16,[113,374,151],{"emptyLinePlaceholder":150},[113,376,378,380,383,385,388,391,394],{"class":115,"line":377},17,[113,379,157],{"class":119},[113,381,382],{"class":160}," consent",[113,384,164],{"class":119},[113,386,387],{"class":170}," useForm",[113,389,390],{"class":123},"({ schema: consentSchema, key: ",[113,392,393],{"class":130},"'consent'",[113,395,396],{"class":123},", defaultValues: saved.consent })\n",[113,398,400,402,405,407,409,412,415],{"class":115,"line":399},18,[113,401,157],{"class":119},[113,403,404],{"class":160}," shipping",[113,406,164],{"class":119},[113,408,387],{"class":170},[113,410,411],{"class":123},"({ schema: shippingSchema, key: ",[113,413,414],{"class":130},"'shipping'",[113,416,417],{"class":123},", defaultValues: saved.shipping })\n",[113,419,421],{"class":115,"line":420},19,[113,422,151],{"emptyLinePlaceholder":150},[113,424,426,428,431,433,436],{"class":115,"line":425},20,[113,427,157],{"class":119},[113,429,430],{"class":160}," wizard",[113,432,164],{"class":119},[113,434,435],{"class":170}," useWizard",[113,437,438],{"class":123},"({\n",[113,440,442,445,447],{"class":115,"line":441},21,[113,443,444],{"class":123},"  steps: [",[113,446,94],{"class":170},[113,448,449],{"class":123},"(consent), shipping],\n",[113,451,453,456],{"class":115,"line":452},22,[113,454,455],{"class":123},"  defaultStatuses: saved.gates, ",[113,457,458],{"class":225},"\u002F\u002F e.g. { consent: { gate: 'cleared' } }\n",[113,460,462],{"class":115,"line":461},23,[113,463,464],{"class":123},"})\n",[17,466,467,468,470,471,473,474,477,478,481],{},"Values in through ",[21,469,23],{},", gate in through ",[21,472,31],{},", both from ",[21,475,476],{},"saved",". The ",[21,479,480],{},"gates"," column holds one entry per gate, so ten gates cost the same zero extra tables as one.",[37,483,485],{"id":484},"writing-it-back","Writing it back",[17,487,488],{},"A gate clears on its member form's clean submit, so the write-back rides on the submit you already have. Record the flag on the server there. The client's gate latch has already moved, so there is nothing else to sync:",[104,490,492],{"className":106,"code":491,"language":108,"meta":109,"style":109},"consent.handleSubmit(async (data) => {\n  await confirmConsentOnServer(sessionId, data) \u002F\u002F server records gates.consent = 'cleared'\n})\n",[21,493,494,521,535],{"__ignoreMap":109},[113,495,496,499,502,504,507,510,513,516,519],{"class":115,"line":116},[113,497,498],{"class":123},"consent.",[113,500,501],{"class":170},"handleSubmit",[113,503,180],{"class":123},[113,505,506],{"class":119},"async",[113,508,509],{"class":123}," (",[113,511,512],{"class":246},"data",[113,514,515],{"class":123},") ",[113,517,518],{"class":119},"=>",[113,520,240],{"class":123},[113,522,523,526,529,532],{"class":115,"line":134},[113,524,525],{"class":119},"  await",[113,527,528],{"class":170}," confirmConsentOnServer",[113,530,531],{"class":123},"(sessionId, data) ",[113,533,534],{"class":225},"\u002F\u002F server records gates.consent = 'cleared'\n",[113,536,537],{"class":115,"line":147},[113,538,464],{"class":123},[17,540,541,542,548],{},"To re-seal a gate later, a revoked consent or a rolled-back approval, ",[56,543,545],{"href":544},"\u002Fdocs\u002Fmultistep\u002Fgate#withdrawing-a-gate",[21,546,547],{},"wizard.relock(key, commit)"," runs your server write and re-seals only if it resolves clean, keeping the row and the client in step.",[37,550,552],{"id":551},"the-server-stays-the-authority","The server stays the authority",[17,554,555,556,558,559,561,562,565],{},"The seed is a first-paint optimization, not an access check. Because ",[21,557,31],{}," seeds the gate at construction, a client that tampers with its saved ",[21,560,480],{}," value renders the flow open. That is fine, as long as the guarantee lives where ",[21,563,564],{},"gate()"," puts it: in the data, enforced by the server. Re-check the prerequisite on every downstream write, so a spoofed client seed shows an open rail but the server still refuses the mutation. The gate keeps the client honest for free; the server keeps it honest for real.",[17,567,568,569,572],{},"This is why gate clearance is not a field in the form's schema. Keeping it out of ",[21,570,571],{},"values"," means it is never submitted as if it were user data, never validated as if the client owned it, and never mistaken for the authority. It is server-owned state that the client is merely allowed to cache.",[37,574,576],{"id":575},"where-to-next","Where to next",[45,578,579,584,595,604],{},[48,580,581,583],{},[56,582,59],{"href":58}," for the active-step half of the round-trip.",[48,585,586,590,591,594],{},[56,587,588],{"href":91},[21,589,94],{}," for the prerequisite model and ",[21,592,593],{},"relock",".",[48,596,597,600,601,603],{},[56,598,599],{"href":85},"Statuses"," for the full ",[21,602,31],{}," seed shape.",[48,605,606,609,610,612],{},[56,607,608],{"href":73},"Defaults from the schema"," for how ",[21,611,23],{}," rehydrates each form.",[614,615,616],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":109,"searchDepth":134,"depth":134,"links":618},[619,620,621,622,623],{"id":39,"depth":134,"text":40},{"id":98,"depth":134,"text":99},{"id":484,"depth":134,"text":485},{"id":551,"depth":134,"text":552},{"id":575,"depth":134,"text":576},"Persist a wizard across sessions with no extra tables. Form values ride in through defaultValues, gate clearance through defaultStatuses, and the server stays the authority on every gate.","md",{},[628,631,634,637],{"label":629,"value":630},"Category","Restore \u002F persist",{"label":632,"value":633,"kind":21},"Restores","step · values · gates",{"label":635,"value":636},"Storage","one row, no extra tables",{"label":638,"value":639},"Authority","the server, re-checked per write","\u002Fdocs\u002Fmultistep\u002Fresumable-wizards",{"title":5,"description":624},null,"docs\u002Fmultistep\u002Fresumable-wizards","67rOWL25dgQRgaKeFCQfDPYOtAcgC91Q8Gv9i77Yor0",1784326690529]