[{"data":1,"prerenderedAt":918},["ShallowReactive",2],{"content-\u002Fdocs\u002Frecipes\u002Fblank-inputs":3},{"id":4,"title":5,"body":6,"description":911,"extension":912,"meta":913,"navigation":331,"path":914,"seo":915,"stem":916,"__hash__":917},"docs\u002Fdocs\u002Frecipes\u002Fblank-inputs.md","Blank inputs",{"type":7,"value":8,"toc":897},"minimark",[9,18,29,34,44,65,88,102,109,118,222,232,236,246,259,263,269,285,293,299,418,430,437,440,626,637,641,667,674,817,823,827,837,848,852,893],[10,11,13,17],"h1",{"id":12},"blank-the-storage-display-divergence-side-channel",[14,15,16],"code",{},"blank"," — the storage \u002F display divergence side-channel",[19,20,21,22,24,25,28],"p",{},"The form library tracks one extra bit per primitive leaf called ",[14,23,16],{},".\nYou won't need to think about it day-to-day — submit \u002F validate already\nincorporate it, and ",[14,26,27],{},"form.errors"," reflects it reactively. When you do\nhit it, this is the model.",[30,31,33],"h2",{"id":32},"why-it-exists","Why it exists",[19,35,36,37,43],{},"The whole library obeys one principle: ",[38,39,40],"strong",{},[14,41,42],{},"errors = f(schema, state)",".\nStorage plus the schema tell you whether the form is valid — except\nfor one case.",[19,45,46,47,50,51,54,55,58,59,61,62,64],{},"Numeric inputs lie. A ",[14,48,49],{},"\u003Cinput type=\"number\">"," whose value the user has\njust cleared shows ",[14,52,53],{},"''"," in the DOM, but the slim shape requires a\nnumber, so storage holds ",[14,56,57],{},"0",". The schema can't tell the difference\nbetween \"user typed ",[14,60,57],{},"\" and \"user supplied nothing\" — both produce\n",[14,63,57],{}," in storage. Without a side-channel, the runtime would either:",[66,67,68,79],"ul",{},[69,70,71,72,74,75,78],"li",{},"Trust storage and silently submit ",[14,73,57],{}," for an unfilled required field\n(the public-housing-form footgun: \"Income? ",[14,76,77],{},"$0",". Approved.\").",[69,80,81,82,84,85,87],{},"Re-define ",[14,83,57],{}," as \"definitely blank,\" which loses the case where the\nuser actually meant ",[14,86,57],{},".",[19,89,90,93,94,97,98,101],{},[14,91,92],{},"blankPaths"," is the side-channel. It's a reactive ",[14,95,96],{},"Set\u003CPathKey>","\nrecording paths where the runtime knows storage and the visible\ndisplay diverge. The schema author writes ",[14,99,100],{},"z.number()"," and gets the\n\"empty input\" signal back without inventing a sentinel value.",[30,103,105,106,108],{"id":104},"when-blank-applies","When ",[14,107,16],{}," applies",[19,110,111,113,114,117],{},[14,112,16],{}," auto-marks ",[38,115,116],{},"numeric leaves only",". The asymmetry is real:",[119,120,121,140],"table",{},[122,123,124],"thead",{},[125,126,127,131,134,137],"tr",{},[128,129,130],"th",{},"Type",[128,132,133],{},"Storage slim default",[128,135,136],{},"DOM \"empty\"",[128,138,139],{},"Need the side-channel?",[141,142,143,165,186,204],"tbody",{},[125,144,145,151,155,159],{},[146,147,148],"td",{},[14,149,150],{},"number",[146,152,153],{},[14,154,57],{},[146,156,157],{},[14,158,53],{},[146,160,161,164],{},[38,162,163],{},"Yes"," — storage and display diverge.",[125,166,167,172,177,181],{},[146,168,169],{},[14,170,171],{},"bigint",[146,173,174],{},[14,175,176],{},"0n",[146,178,179],{},[14,180,53],{},[146,182,183,185],{},[38,184,163],{}," — same reason.",[125,187,188,193,197,201],{},[146,189,190],{},[14,191,192],{},"string",[146,194,195],{},[14,196,53],{},[146,198,199],{},[14,200,53],{},[146,202,203],{},"No — they match byte-for-byte.",[125,205,206,211,216,219],{},[146,207,208],{},[14,209,210],{},"boolean",[146,212,213],{},[14,214,215],{},"false",[146,217,218],{},"unchecked",[146,220,221],{},"No — they match.",[19,223,224,225,228,229,231],{},"For strings and booleans the schema sees what the user sees. Require\nnon-empty strings via ",[14,226,227],{},"z.string().min(1)"," — the refinement error fires\nthe moment storage is ",[14,230,53],{},", schema speaking.",[30,233,235],{"id":234},"lifecycle-numeric","Lifecycle (numeric)",[237,238,244],"pre",{"className":239,"code":241,"language":242,"meta":243},[240],"language-text","form mounts (no defaults)\n  → blankPaths.add('income')\n  → form.errors.income = [{ code: 'atta:no-value-supplied', … }]\n  → form.fields.income.blank === true\n\nuser types \"5\"\n  → blankPaths.delete('income')\n  → form.errors.income = undefined\n  → form.fields.income.blank === false\n\nuser clears the input (backspace)\n  → directive sees el.value === ''\n  → blankPaths.add('income')\n  → form.errors.income re-appears reactively\n  → form.fields.income.blank === true\n\nuser types \"0\"\n  → blankPaths stays empty (the value is intentional)\n  → form.errors.income stays undefined\n","text","",[14,245,241],{"__ignoreMap":243},[19,247,248,250,251,254,255,258],{},[14,249,42],{}," holds at every step — ",[14,252,253],{},"state"," includes\n",[14,256,257],{},"(form.value, blankPaths)",", and the function recomputes whenever\neither changes.",[30,260,262],{"id":261},"lifecycle-string","Lifecycle (string)",[237,264,267],{"className":265,"code":266,"language":242,"meta":243},[240],"form mounts (no defaults)\n  → blankPaths empty (strings don't auto-mark)\n  → form.errors.email = undefined          (z.string() accepts '')\n  → form.fields.email.blank === false\n\nuser types \"hi\" then deletes\n  → blankPaths still empty\n  → form.errors.email still undefined      (z.string() still accepts '')\n  → form.fields.email.blank === false\n",[14,268,266],{"__ignoreMap":243},[19,270,271,272,274,275,277,278,281,282,284],{},"If the schema is ",[14,273,227],{}," instead, the lifecycle is the\nsame on ",[14,276,92],{}," — but ",[14,279,280],{},"form.errors.email"," carries a refinement\nerror whenever storage is ",[14,283,53],{},", because that's the schema speaking.\nThe blank channel stays out of it.",[30,286,288,289,292],{"id":287},"explicit-opt-in-for-any-primitive-the-unset-sentinel","Explicit opt-in for any primitive: the ",[14,290,291],{},"unset"," sentinel",[19,294,295,296,298],{},"Sometimes you do want a string or boolean leaf to start blank — a\n\"please choose\" indicator on a checkbox, a deferred-fill text field.\nThat's an explicit consumer signal, not runtime inference. Use the\n",[14,297,291],{}," sentinel:",[237,300,304],{"className":301,"code":302,"language":303,"meta":243,"style":243},"language-ts shiki shiki-themes github-light github-dark","import { unset, useForm } from 'attaform\u002Fzod'\n\nuseForm({\n  schema: z.object({ agreed: z.boolean(), note: z.string() }),\n  defaultValues: { agreed: unset, note: unset },\n})\n\n\u002F\u002F Or imperatively:\nform.setValue('agreed', unset)\nform.reset({ note: unset })\n","ts",[14,305,306,326,333,343,365,371,377,382,389,407],{"__ignoreMap":243},[307,308,311,315,319,322],"span",{"class":309,"line":310},"line",1,[307,312,314],{"class":313},"szBVR","import",[307,316,318],{"class":317},"sVt8B"," { unset, useForm } ",[307,320,321],{"class":313},"from",[307,323,325],{"class":324},"sZZnC"," 'attaform\u002Fzod'\n",[307,327,329],{"class":309,"line":328},2,[307,330,332],{"emptyLinePlaceholder":331},true,"\n",[307,334,336,340],{"class":309,"line":335},3,[307,337,339],{"class":338},"sScJk","useForm",[307,341,342],{"class":317},"({\n",[307,344,346,349,352,355,357,360,362],{"class":309,"line":345},4,[307,347,348],{"class":317},"  schema: z.",[307,350,351],{"class":338},"object",[307,353,354],{"class":317},"({ agreed: z.",[307,356,210],{"class":338},[307,358,359],{"class":317},"(), note: z.",[307,361,192],{"class":338},[307,363,364],{"class":317},"() }),\n",[307,366,368],{"class":309,"line":367},5,[307,369,370],{"class":317},"  defaultValues: { agreed: unset, note: unset },\n",[307,372,374],{"class":309,"line":373},6,[307,375,376],{"class":317},"})\n",[307,378,380],{"class":309,"line":379},7,[307,381,332],{"emptyLinePlaceholder":331},[307,383,385],{"class":309,"line":384},8,[307,386,388],{"class":387},"sJ8bj","\u002F\u002F Or imperatively:\n",[307,390,392,395,398,401,404],{"class":309,"line":391},9,[307,393,394],{"class":317},"form.",[307,396,397],{"class":338},"setValue",[307,399,400],{"class":317},"(",[307,402,403],{"class":324},"'agreed'",[307,405,406],{"class":317},", unset)\n",[307,408,410,412,415],{"class":309,"line":409},10,[307,411,394],{"class":317},[307,413,414],{"class":338},"reset",[307,416,417],{"class":317},"({ note: unset })\n",[19,419,420,422,423,425,426,429],{},[14,421,291],{}," works at any primitive leaf and adds the path to ",[14,424,92],{},".\nCombined with required schemas, it surfaces a ",[14,427,428],{},"atta:no-value-supplied","\nerror reactively — same lifecycle as the numeric case, just driven by\nyour intent rather than runtime inference.",[30,431,433,434,436],{"id":432},"how-to-read-blank-in-your-ui","How to read ",[14,435,16],{}," in your UI",[19,438,439],{},"The library never renders. It exposes the signal; your component\ndecides what to do.",[237,441,445],{"className":442,"code":443,"language":444,"meta":243,"style":243},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup lang=\"ts\">\n  const form = useForm({ schema })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cinput v-register=\"form.register('income')\" \u002F>\n\n  \u003C!-- show errors only after the user has touched the field -->\n  \u003Cp v-if=\"form.errors.income && form.fields.income.touched\" class=\"error\">\n    {{ form.errors.income[0].message }}\n  \u003C\u002Fp>\n\n  \u003C!-- separately, an \"unanswered\" hint that distinguishes from errors -->\n  \u003Cspan v-if=\"form.fields.income.blank\" class=\"hint\"> Required — please enter a number \u003C\u002Fspan>\n\u003C\u002Ftemplate>\n","vue",[14,446,447,471,489,498,502,511,530,534,539,563,568,578,583,589,617],{"__ignoreMap":243},[307,448,449,452,456,459,462,465,468],{"class":309,"line":310},[307,450,451],{"class":317},"\u003C",[307,453,455],{"class":454},"s9eBZ","script",[307,457,458],{"class":338}," setup",[307,460,461],{"class":338}," lang",[307,463,464],{"class":317},"=",[307,466,467],{"class":324},"\"ts\"",[307,469,470],{"class":317},">\n",[307,472,473,476,480,483,486],{"class":309,"line":328},[307,474,475],{"class":313},"  const",[307,477,479],{"class":478},"sj4cs"," form",[307,481,482],{"class":313}," =",[307,484,485],{"class":338}," useForm",[307,487,488],{"class":317},"({ schema })\n",[307,490,491,494,496],{"class":309,"line":335},[307,492,493],{"class":317},"\u003C\u002F",[307,495,455],{"class":454},[307,497,470],{"class":317},[307,499,500],{"class":309,"line":345},[307,501,332],{"emptyLinePlaceholder":331},[307,503,504,506,509],{"class":309,"line":367},[307,505,451],{"class":317},[307,507,508],{"class":454},"template",[307,510,470],{"class":317},[307,512,513,516,519,522,524,527],{"class":309,"line":373},[307,514,515],{"class":317},"  \u003C",[307,517,518],{"class":454},"input",[307,520,521],{"class":338}," v-register",[307,523,464],{"class":317},[307,525,526],{"class":324},"\"form.register('income')\"",[307,528,529],{"class":317}," \u002F>\n",[307,531,532],{"class":309,"line":379},[307,533,332],{"emptyLinePlaceholder":331},[307,535,536],{"class":309,"line":384},[307,537,538],{"class":387},"  \u003C!-- show errors only after the user has touched the field -->\n",[307,540,541,543,545,548,550,553,556,558,561],{"class":309,"line":391},[307,542,515],{"class":317},[307,544,19],{"class":454},[307,546,547],{"class":338}," v-if",[307,549,464],{"class":317},[307,551,552],{"class":324},"\"form.errors.income && form.fields.income.touched\"",[307,554,555],{"class":338}," class",[307,557,464],{"class":317},[307,559,560],{"class":324},"\"error\"",[307,562,470],{"class":317},[307,564,565],{"class":309,"line":409},[307,566,567],{"class":317},"    {{ form.errors.income[0].message }}\n",[307,569,571,574,576],{"class":309,"line":570},11,[307,572,573],{"class":317},"  \u003C\u002F",[307,575,19],{"class":454},[307,577,470],{"class":317},[307,579,581],{"class":309,"line":580},12,[307,582,332],{"emptyLinePlaceholder":331},[307,584,586],{"class":309,"line":585},13,[307,587,588],{"class":387},"  \u003C!-- separately, an \"unanswered\" hint that distinguishes from errors -->\n",[307,590,592,594,596,598,600,603,605,607,610,613,615],{"class":309,"line":591},14,[307,593,515],{"class":317},[307,595,307],{"class":454},[307,597,547],{"class":338},[307,599,464],{"class":317},[307,601,602],{"class":324},"\"form.fields.income.blank\"",[307,604,555],{"class":338},[307,606,464],{"class":317},[307,608,609],{"class":324},"\"hint\"",[307,611,612],{"class":317},"> Required — please enter a number \u003C\u002F",[307,614,307],{"class":454},[307,616,470],{"class":317},[307,618,620,622,624],{"class":309,"line":619},15,[307,621,493],{"class":317},[307,623,508],{"class":454},[307,625,470],{"class":317},[19,627,628,629,632,633,636],{},"Reading ",[14,630,631],{},"form.errors.income"," directly gives you whatever the schema\nand the blank channel produced. Reading ",[14,634,635],{},"form.fields.income.blank","\ngives you the raw \"did the user supply something?\" bit, useful for\npre-error indicators or progress meters.",[30,638,640],{"id":639},"submit-time-integration","Submit-time integration",[19,642,643,646,647,650,651,654,655,657,658,662,663,666],{},[14,644,645],{},"handleSubmit",", ",[14,648,649],{},"validate",", and ",[14,652,653],{},"validateAsync"," all consult the same\nreactive store. A required path that's blank produces a\n",[14,656,428],{}," entry in their response, no separate API call\nneeded. Conversely, a successful submit means ",[659,660,661],"em",{},"both"," refinement\nvalidation passed ",[659,664,665],{},"and"," every required path has a non-blank value.",[19,668,669,670,673],{},"Your ",[14,671,672],{},"onError"," callback gets the merged error list:",[237,675,677],{"className":301,"code":676,"language":303,"meta":243,"style":243},"const handler = form.handleSubmit(\n  (values) => api.submit(values),\n  (errors) => {\n    const blank = errors.filter((e) => e.code === 'atta:no-value-supplied')\n    const refinement = errors.filter((e) => e.code !== 'atta:no-value-supplied')\n    \u002F\u002F ... your UX\n  }\n)\n",[14,678,679,697,721,735,773,803,808,813],{"__ignoreMap":243},[307,680,681,684,687,689,692,694],{"class":309,"line":310},[307,682,683],{"class":313},"const",[307,685,686],{"class":478}," handler",[307,688,482],{"class":313},[307,690,691],{"class":317}," form.",[307,693,645],{"class":338},[307,695,696],{"class":317},"(\n",[307,698,699,702,706,709,712,715,718],{"class":309,"line":328},[307,700,701],{"class":317},"  (",[307,703,705],{"class":704},"s4XuR","values",[307,707,708],{"class":317},") ",[307,710,711],{"class":313},"=>",[307,713,714],{"class":317}," api.",[307,716,717],{"class":338},"submit",[307,719,720],{"class":317},"(values),\n",[307,722,723,725,728,730,732],{"class":309,"line":335},[307,724,701],{"class":317},[307,726,727],{"class":704},"errors",[307,729,708],{"class":317},[307,731,711],{"class":313},[307,733,734],{"class":317}," {\n",[307,736,737,740,743,745,748,751,754,757,759,761,764,767,770],{"class":309,"line":345},[307,738,739],{"class":313},"    const",[307,741,742],{"class":478}," blank",[307,744,482],{"class":313},[307,746,747],{"class":317}," errors.",[307,749,750],{"class":338},"filter",[307,752,753],{"class":317},"((",[307,755,756],{"class":704},"e",[307,758,708],{"class":317},[307,760,711],{"class":313},[307,762,763],{"class":317}," e.code ",[307,765,766],{"class":313},"===",[307,768,769],{"class":324}," 'atta:no-value-supplied'",[307,771,772],{"class":317},")\n",[307,774,775,777,780,782,784,786,788,790,792,794,796,799,801],{"class":309,"line":367},[307,776,739],{"class":313},[307,778,779],{"class":478}," refinement",[307,781,482],{"class":313},[307,783,747],{"class":317},[307,785,750],{"class":338},[307,787,753],{"class":317},[307,789,756],{"class":704},[307,791,708],{"class":317},[307,793,711],{"class":313},[307,795,763],{"class":317},[307,797,798],{"class":313},"!==",[307,800,769],{"class":324},[307,802,772],{"class":317},[307,804,805],{"class":309,"line":373},[307,806,807],{"class":387},"    \u002F\u002F ... your UX\n",[307,809,810],{"class":309,"line":379},[307,811,812],{"class":317},"  }\n",[307,814,815],{"class":309,"line":384},[307,816,772],{"class":317},[19,818,819,820,822],{},"The error code is stable (",[14,821,428],{},"); filter on it when\nyou want to display blank-required cases differently from refinement\nfailures.",[30,824,826],{"id":825},"persistence","Persistence",[19,828,829,830,832,833,836],{},"Persisted drafts carry ",[14,831,92],{}," alongside ",[14,834,835],{},"form"," so a hydrated\nform lands in the same UI state the user left it. A user who cleared\na numeric input, navigated away, and came back sees the field empty\nand the error reappear — same lifecycle, just resumed.",[19,838,839,840,847],{},"See ",[841,842,844],"a",{"href":843},".\u002Fpersistence",[14,845,846],{},"persistence.md"," for the\nhydration boundary.",[30,849,851],{"id":850},"tldr","TL;DR",[66,853,854,868,876,881,890],{},[69,855,856,858,859,861,862,864,865,867],{},[14,857,16],{}," exists for numeric leaves where storage is ",[14,860,57],{},"\u002F",[14,863,176],{}," but\ndisplay is ",[14,866,53],{},". That's the only place runtime inference is needed.",[69,869,870,871,861,873,875],{},"Strings and booleans don't auto-mark — ",[14,872,53],{},[14,874,215],{}," are the same\nbyte-for-byte at storage and display, and the schema is the\nauthority on whether they're acceptable.",[69,877,878,880],{},[14,879,291],{}," is the universal explicit opt-in for any primitive type.",[69,882,883,885,886,889],{},[14,884,27],{}," is reactive end-to-end: a blank required path\nproduces an error the moment it's true, no ",[14,887,888],{},"validate()"," call\nrequired.",[69,891,892],{},"The library exposes the signal; the UI decides what to render and\nwhen.",[894,895,896],"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 .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 .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);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":243,"searchDepth":328,"depth":328,"links":898},[899,900,902,903,904,906,908,909,910],{"id":32,"depth":328,"text":33},{"id":104,"depth":328,"text":901},"When blank applies",{"id":234,"depth":328,"text":235},{"id":261,"depth":328,"text":262},{"id":287,"depth":328,"text":905},"Explicit opt-in for any primitive: the unset sentinel",{"id":432,"depth":328,"text":907},"How to read blank in your UI",{"id":639,"depth":328,"text":640},{"id":825,"depth":328,"text":826},{"id":850,"depth":328,"text":851},"How Attaform handles cleared numeric inputs that storage can't represent: the unset sentinel, the blank field-state bit, and the no-value-supplied error.","md",{},"\u002Fdocs\u002Frecipes\u002Fblank-inputs",{"title":5,"description":911},"docs\u002Frecipes\u002Fblank-inputs","D5Kbl24ItOQbg4gPI_vEKS_zcvQSvw85P9D9xfII1eM",1778164225274]